diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json
index 837af5f..5e0167b 100644
--- a/contracts/admin-openapi.json
+++ b/contracts/admin-openapi.json
@@ -6121,6 +6121,125 @@
"x-permissions": ["app-user:view"]
}
},
+ "/admin/external-admin-users": {
+ "get": {
+ "operationId": "listExternalAdminUsers",
+ "parameters": [
+ { "$ref": "#/components/parameters/AppCodeHeader" },
+ { "$ref": "#/components/parameters/Page" },
+ { "$ref": "#/components/parameters/PageSize" },
+ { "$ref": "#/components/parameters/Keyword" },
+ { "$ref": "#/components/parameters/Status" }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/ExternalAdminUserPageResponse"
+ }
+ },
+ "x-permission": "external-admin-user:view",
+ "x-permissions": ["external-admin-user:view"]
+ },
+ "post": {
+ "operationId": "createExternalAdminUser",
+ "parameters": [
+ { "$ref": "#/components/parameters/AppCodeHeader" }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExternalAdminUserCreateInput"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "$ref": "#/components/responses/ExternalAdminUserResponse"
+ }
+ },
+ "x-permission": "external-admin-user:create",
+ "x-permissions": ["external-admin-user:create"]
+ }
+ },
+ "/admin/external-admin-users/target": {
+ "get": {
+ "operationId": "lookupExternalAdminUserTarget",
+ "parameters": [
+ { "$ref": "#/components/parameters/AppCodeHeader" },
+ {
+ "in": "query",
+ "name": "display_user_id",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/ExternalAdminUserTargetResponse"
+ }
+ },
+ "x-permission": "external-admin-user:create",
+ "x-permissions": ["external-admin-user:create"]
+ }
+ },
+ "/admin/external-admin-users/{id}/status": {
+ "patch": {
+ "operationId": "updateExternalAdminUserStatus",
+ "parameters": [
+ { "$ref": "#/components/parameters/AppCodeHeader" },
+ { "$ref": "#/components/parameters/Id" }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExternalAdminUserStatusInput"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/ExternalAdminUserResponse"
+ }
+ },
+ "x-permission": "external-admin-user:status",
+ "x-permissions": ["external-admin-user:status"]
+ }
+ },
+ "/admin/external-admin-users/{id}/password": {
+ "post": {
+ "operationId": "resetExternalAdminUserPassword",
+ "parameters": [
+ { "$ref": "#/components/parameters/AppCodeHeader" },
+ { "$ref": "#/components/parameters/Id" }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExternalAdminUserPasswordInput"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/ExternalAdminUserResponse"
+ }
+ },
+ "x-permission": "external-admin-user:reset-password",
+ "x-permissions": ["external-admin-user:reset-password"]
+ }
+ },
"/exports/app-users": {
"post": {
"operationId": "appExportUsers",
@@ -8550,6 +8669,30 @@
}
}
},
+ "ExternalAdminUserPageResponse": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/ApiResponseExternalAdminUserPage" }
+ }
+ }
+ },
+ "ExternalAdminUserResponse": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/ApiResponseExternalAdminUser" }
+ }
+ }
+ },
+ "ExternalAdminUserTargetResponse": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/ApiResponseExternalAdminUserTarget" }
+ }
+ }
+ },
"AgencyPageResponse": {
"description": "OK",
"content": {
@@ -9393,6 +9536,124 @@
}
]
},
+ "ApiResponseExternalAdminUserPage": {
+ "allOf": [
+ { "$ref": "#/components/schemas/Envelope" },
+ {
+ "type": "object",
+ "properties": {
+ "data": { "$ref": "#/components/schemas/ApiPageExternalAdminUser" }
+ }
+ }
+ ]
+ },
+ "ApiResponseExternalAdminUser": {
+ "allOf": [
+ { "$ref": "#/components/schemas/Envelope" },
+ {
+ "type": "object",
+ "properties": {
+ "data": { "$ref": "#/components/schemas/ExternalAdminUser" }
+ }
+ }
+ ]
+ },
+ "ApiResponseExternalAdminUserTarget": {
+ "allOf": [
+ { "$ref": "#/components/schemas/Envelope" },
+ {
+ "type": "object",
+ "properties": {
+ "data": { "$ref": "#/components/schemas/ExternalAdminUserTargetLookup" }
+ }
+ }
+ ]
+ },
+ "ApiPageExternalAdminUser": {
+ "type": "object",
+ "required": ["items", "page", "pageSize", "total"],
+ "properties": {
+ "items": {
+ "type": "array",
+ "items": { "$ref": "#/components/schemas/ExternalAdminUser" }
+ },
+ "page": { "type": "integer", "minimum": 1 },
+ "pageSize": { "type": "integer", "minimum": 1 },
+ "total": { "type": "integer", "format": "int64", "minimum": 0 }
+ }
+ },
+ "ExternalAdminUserTarget": {
+ "type": "object",
+ "required": ["userId", "displayUserId", "status"],
+ "properties": {
+ "userId": { "type": "string" },
+ "displayUserId": { "type": "string" },
+ "defaultDisplayUserId": { "type": "string" },
+ "prettyDisplayUserId": { "type": "string" },
+ "prettyId": { "type": "string" },
+ "username": { "type": "string" },
+ "avatar": { "type": "string" },
+ "status": { "type": "string" }
+ }
+ },
+ "ExternalAdminUserTargetLookup": {
+ "type": "object",
+ "required": ["linkedUser"],
+ "properties": {
+ "linkedUser": { "$ref": "#/components/schemas/ExternalAdminUserTarget" },
+ "existingExternalAdminUser": { "$ref": "#/components/schemas/ExternalAdminUser" }
+ }
+ },
+ "ExternalAdminUser": {
+ "type": "object",
+ "required": ["id", "appCode", "username", "status", "linkedUser", "permissions", "createdAtMs"],
+ "properties": {
+ "id": { "type": "integer" },
+ "appCode": { "type": "string" },
+ "username": { "type": "string" },
+ "status": { "type": "string", "enum": ["active", "disabled"] },
+ "linkedUser": { "$ref": "#/components/schemas/ExternalAdminUserTarget" },
+ "permissions": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "createdByAdminId": { "type": "integer" },
+ "lastLoginAtMs": { "type": "integer", "format": "int64", "nullable": true },
+ "createdAtMs": { "type": "integer", "format": "int64" },
+ "updatedAtMs": { "type": "integer", "format": "int64" }
+ }
+ },
+ "ExternalAdminUserCreateInput": {
+ "type": "object",
+ "required": ["targetUserId", "username", "password"],
+ "additionalProperties": false,
+ "properties": {
+ "targetUserId": { "type": "string", "minLength": 1 },
+ "username": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 64,
+ "pattern": "^[A-Za-z0-9._-]+$"
+ },
+ "password": { "type": "string", "minLength": 8, "maxLength": 72 }
+ }
+ },
+ "ExternalAdminUserStatusInput": {
+ "type": "object",
+ "required": ["status"],
+ "additionalProperties": false,
+ "properties": {
+ "status": { "type": "string", "enum": ["active", "disabled"] }
+ }
+ },
+ "ExternalAdminUserPasswordInput": {
+ "type": "object",
+ "required": ["password"],
+ "additionalProperties": false,
+ "properties": {
+ "password": { "type": "string", "minLength": 8, "maxLength": 72 }
+ }
+ },
"ApiPageAppUser": {
"type": "object",
"required": ["items", "page", "pageSize", "total"],
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
index dba22a3..cee04e8 100644
--- a/deploy/nginx.conf
+++ b/deploy/nginx.conf
@@ -37,6 +37,15 @@ server {
proxy_pass http://127.0.0.1:13100/readyz;
}
+ location = /external-admin {
+ return 301 /external-admin/$is_args$args;
+ }
+
+ # 外管使用独立 HTML 入口;深链必须回退到自己的入口,不能落入主后台的 index.html。
+ location ^~ /external-admin/ {
+ try_files $uri $uri/ /external-admin/index.html;
+ }
+
location / {
try_files $uri $uri/ /index.html;
}
diff --git a/external-admin/index.html b/external-admin/index.html
new file mode 100644
index 0000000..c7759b6
--- /dev/null
+++ b/external-admin/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ HYApp 外管
+
+
+
+
+
+
diff --git a/external-admin/src/ExternalAdminApp.jsx b/external-admin/src/ExternalAdminApp.jsx
new file mode 100644
index 0000000..aaad881
--- /dev/null
+++ b/external-admin/src/ExternalAdminApp.jsx
@@ -0,0 +1,53 @@
+import { lazy, Suspense } from "react";
+import { Navigate, Route, Routes } from "react-router-dom";
+import { RequireExternalAuth, RequireExternalCapability, RequirePasswordChanged } from "./auth/RouteGuards.jsx";
+import { ExternalAdminLayout } from "./layout/ExternalAdminLayout.jsx";
+import { ExternalPageState } from "./shared/PageComponents.jsx";
+
+const LoginPage = lazy(() => import("./pages/LoginPage.jsx"));
+const OverviewPage = lazy(() => import("./pages/OverviewPage.jsx"));
+const UsersPage = lazy(() => import("./pages/UsersPage.jsx"));
+const BansPage = lazy(() => import("./pages/BansPage.jsx"));
+const OrganizationPage = lazy(() => import("./pages/OrganizationPage.jsx"));
+const RoomsPage = lazy(() => import("./pages/RoomsPage.jsx"));
+const GrantsPage = lazy(() => import("./pages/GrantsPage.jsx"));
+const BannersPage = lazy(() => import("./pages/BannersPage.jsx"));
+const ChangePasswordPage = lazy(() => import("./pages/ChangePasswordPage.jsx"));
+
+export function ExternalAdminApp() {
+ return (
+ }>
+
+ } path="/login" />
+ }>
+ } path="/change-password" />
+ }>
+ }>
+ } index />
+ } path="/overview" />
+ }>
+ } path="/users" />
+
+ }>
+ } path="/bans" />
+
+ }>
+ } path="/organization/:kind" />
+
+ }>
+ } path="/rooms" />
+
+ }>
+ } path="/grants" />
+
+ }>
+ } path="/banners" />
+
+ } path="*" />
+
+
+
+
+
+ );
+}
diff --git a/external-admin/src/api/auth.js b/external-admin/src/api/auth.js
new file mode 100644
index 0000000..596ac33
--- /dev/null
+++ b/external-admin/src/api/auth.js
@@ -0,0 +1,94 @@
+import { externalRequest, rememberExternalCsrfToken } from "./client.js";
+import { arrayValue, asRecord, textValue } from "./normalizers.js";
+
+// These external-session auth routes intentionally have no main-admin OpenAPI operation.
+const EXTERNAL_AUTH_PATHS = Object.freeze({
+ apps: "/auth/apps",
+ changePassword: "/auth/change-password",
+ login: "/auth/login",
+ logout: "/auth/logout",
+ me: "/auth/me"
+});
+
+export async function listExternalApps() {
+ const data = await externalRequest(EXTERNAL_AUTH_PATHS.apps, { notifyUnauthorized: false });
+ const source = asRecord(data);
+ return arrayValue(data, source.items, source.apps)
+ .map((item) => normalizeApp(item))
+ .filter((item) => item.appCode);
+}
+
+export async function loginExternal(payload) {
+ const data = await externalRequest(EXTERNAL_AUTH_PATHS.login, {
+ body: {
+ account: String(payload.account || "").trim(),
+ appCode: String(payload.appCode || "").trim(),
+ password: String(payload.password || "")
+ },
+ method: "POST",
+ notifyUnauthorized: false
+ });
+ rememberExternalCsrfToken(asRecord(data).csrfToken);
+ return normalizeSession(data);
+}
+
+export async function getExternalSession() {
+ const data = await externalRequest(EXTERNAL_AUTH_PATHS.me, { notifyUnauthorized: false });
+ rememberExternalCsrfToken(asRecord(data).csrfToken);
+ return normalizeSession(data);
+}
+
+export async function logoutExternal() {
+ try {
+ await externalRequest(EXTERNAL_AUTH_PATHS.logout, { method: "POST", notifyUnauthorized: false });
+ } finally {
+ rememberExternalCsrfToken("");
+ }
+}
+
+export async function changeExternalPassword(payload) {
+ const data = await externalRequest(EXTERNAL_AUTH_PATHS.changePassword, {
+ body: {
+ currentPassword: payload.oldPassword,
+ newPassword: payload.newPassword
+ },
+ method: "POST"
+ });
+ return data ? normalizeSession(data) : null;
+}
+
+export function normalizeSession(data) {
+ const root = asRecord(data);
+ const accountRecord = asRecord(root.account);
+ const linkedUser = asRecord(root.user || root.linkedUser || root.linked_user);
+ const legacyAccount = asRecord(root.externalAdminUser || root.external_admin_user);
+ const app = asRecord(root.app || linkedUser.app || accountRecord.app);
+ const capabilities = [
+ ...arrayValue(root.capabilities),
+ ...arrayValue(root.permissions),
+ ...arrayValue(accountRecord.capabilities, accountRecord.permissions),
+ ...arrayValue(linkedUser.capabilities, linkedUser.permissions)
+ ]
+ .map(String)
+ .filter(Boolean);
+
+ return {
+ account: textValue(accountRecord.username, accountRecord.account, legacyAccount.username, legacyAccount.account, root.accountName, typeof root.account === "string" ? root.account : "", root.username),
+ appCode: textValue(root.appCode, root.app_code, app.appCode, app.app_code, accountRecord.appCode, accountRecord.app_code, linkedUser.appCode, linkedUser.app_code),
+ appName: textValue(app.name, app.appName, app.app_name, root.appName, root.app_name),
+ capabilities: [...new Set(capabilities)],
+ displayName: textValue(linkedUser.displayName, linkedUser.display_name, linkedUser.name, linkedUser.username, accountRecord.username, legacyAccount.username, root.displayName),
+ id: textValue(accountRecord.id, accountRecord.externalAdminUserId, accountRecord.external_admin_user_id, legacyAccount.id, root.accountId, root.account_id),
+ passwordChangeRequired: Boolean(
+ root.passwordChangeRequired ?? root.password_change_required ?? accountRecord.passwordChangeRequired ?? accountRecord.password_change_required
+ )
+ };
+}
+
+function normalizeApp(value) {
+ const item = typeof value === "string" ? { appCode: value } : asRecord(value);
+ return {
+ appCode: textValue(item.appCode, item.app_code, item.code, item.value),
+ name: textValue(item.name, item.appName, item.app_name, item.label, item.appCode, item.code)
+ };
+}
diff --git a/external-admin/src/api/auth.test.js b/external-admin/src/api/auth.test.js
new file mode 100644
index 0000000..b509f0a
--- /dev/null
+++ b/external-admin/src/api/auth.test.js
@@ -0,0 +1,63 @@
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { changeExternalPassword, listExternalApps, normalizeSession } from "./auth.js";
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("normalizeSession", () => {
+ test("normalizes account objects, fixed app and capabilities without stringifying objects", () => {
+ expect(normalizeSession({
+ account: { id: "ext-1", username: "fami-manager" },
+ app: { appCode: "fami", appName: "Fami" },
+ capabilities: ["user:list"],
+ passwordChangeRequired: true
+ })).toEqual(expect.objectContaining({
+ account: "fami-manager",
+ appCode: "fami",
+ appName: "Fami",
+ capabilities: ["user:list"],
+ passwordChangeRequired: true
+ }));
+ });
+
+ test("merges permission aliases from root and bound user", () => {
+ const session = normalizeSession({
+ appCode: "lalu",
+ permissions: ["host:list"],
+ user: { permissions: ["team:view"], username: "lalu-ops" }
+ });
+ expect(session.capabilities).toEqual(["host:list", "team:view"]);
+ });
+
+ test("keeps the external account separate from the linked App user", () => {
+ const session = normalizeSession({
+ account: { id: "external-account-8", username: "fami-manager" },
+ app: { appCode: "fami", appName: "Fami" },
+ user: { userId: "app-user-9", username: "App Nickname" }
+ });
+ expect(session.account).toBe("fami-manager");
+ expect(session.displayName).toBe("App Nickname");
+ expect(session.id).toBe("external-account-8");
+ });
+});
+
+describe("external auth API contract", () => {
+ test("reads appName from auth/apps items", async () => {
+ vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ code: 0, data: { items: [{ appCode: "fami", appName: "Fami" }], total: 1 } })));
+ await expect(listExternalApps()).resolves.toEqual([{ appCode: "fami", name: "Fami" }]);
+ });
+
+ test("change-password sends currentPassword and never oldPassword", async () => {
+ const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: {} }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await changeExternalPassword({ newPassword: "new-password", oldPassword: "current-password" });
+
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ currentPassword: "current-password", newPassword: "new-password" });
+ });
+});
+
+function jsonResponse(body, status = 200) {
+ return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status });
+}
diff --git a/external-admin/src/api/business.js b/external-admin/src/api/business.js
new file mode 100644
index 0000000..e42aaff
--- /dev/null
+++ b/external-admin/src/api/business.js
@@ -0,0 +1,321 @@
+import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
+import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
+import { externalRequest } from "./client.js";
+import { asRecord, booleanValue, entityId, normalizePage, numberValue, textValue } from "./normalizers.js";
+
+// my-team is external-session scoped and is not part of the main-admin OpenAPI contract yet.
+const EXTERNAL_ONLY_PATHS = Object.freeze({ myTeamAgencies: "/admin/my-team/agencies" });
+
+export function listUsers(query) {
+ return externalRequest(externalOperationPath(API_OPERATIONS.appListUsers), { query }).then((data) => normalizePage(data, normalizeUser));
+}
+
+export function listBannedUsers(query) {
+ return externalRequest(externalOperationPath(API_OPERATIONS.appListBannedUsers), { query }).then((data) => normalizePage(data, normalizeBannedUser));
+}
+
+export function updateUser(userId, payload) {
+ return externalRequest(externalOperationPath(API_OPERATIONS.appUpdateUser, { id: userId }), { body: payload, method: "PATCH" });
+}
+
+export function banUser(userId, payload) {
+ return externalRequest(externalOperationPath(API_OPERATIONS.appBanUser, { id: userId }), { body: payload, method: "POST" });
+}
+
+export function unbanUser(userId, payload = {}) {
+ return externalRequest(externalOperationPath(API_OPERATIONS.appUnbanUser, { id: userId }), { body: payload, method: "POST" });
+}
+
+export function updateUserLevels(userId, payload) {
+ return externalRequest(externalOperationPath(API_OPERATIONS.appAdjustUserLevels, { id: userId }), { body: payload, method: "PUT" });
+}
+
+export async function grantUserVip(targetUserId, payload) {
+ const [programData, levelsData] = await Promise.all([
+ externalRequest(externalOperationPath(API_OPERATIONS.getVipProgram)),
+ externalRequest(externalOperationPath(API_OPERATIONS.getVipConfig))
+ ]);
+ const programRoot = asRecord(programData);
+ const program = asRecord(programRoot.config || programRoot.programConfig || programRoot.program_config || programRoot);
+ const levelsRoot = asRecord(levelsData);
+ const levels = Array.isArray(levelsData) ? levelsData : levelsRoot.levels || [];
+ const level = Number(payload.level);
+
+ if (program.status === "disabled") {
+ throw new Error("当前 App 的 VIP 体系已停用");
+ }
+ if (Array.isArray(levels) && levels.length && !levels.some((item) => Number(item.level) === level)) {
+ throw new Error("请选择当前 App 已配置的 VIP 等级");
+ }
+
+ const command = {
+ commandId: payload.commandId,
+ level,
+ reason: payload.reason,
+ targetUserId
+ };
+ if (program.grantMode === "trial_card" || program.grant_mode === "trial_card") {
+ return externalRequest(externalOperationPath(API_OPERATIONS.grantVipTrialCard), {
+ body: { ...command, durationMs: Number(payload.durationMs) },
+ method: "POST"
+ });
+ }
+ return externalRequest(externalOperationPath(API_OPERATIONS.grantVip), { body: command, method: "POST" });
+}
+
+export function listOrganizations(kind, query = {}) {
+ const routes = {
+ agencies: externalOperationPath(API_OPERATIONS.listAgencies),
+ bds: externalOperationPath(API_OPERATIONS.listBDs),
+ "bd-leaders": externalOperationPath(API_OPERATIONS.listManagers),
+ hosts: externalOperationPath(API_OPERATIONS.listHosts),
+ managers: externalOperationPath(API_OPERATIONS.listManagers),
+ "super-admins": externalOperationPath(API_OPERATIONS.listBDLeaders),
+ team: EXTERNAL_ONLY_PATHS.myTeamAgencies
+ };
+ if (!routes[kind]) {
+ throw new Error("该组织列表尚无安全的数据接口");
+ }
+ const scopedQuery = kind === "super-admins" ? { ...query, status: query.status || "active" } : query;
+ return externalRequest(routes[kind], { query: scopedQuery }).then((data) => {
+ const page = normalizePage(data, normalizeOrganization);
+ if (kind !== "team") {
+ return page;
+ }
+ const source = asRecord(data);
+ return {
+ ...page,
+ totalBdLeaders: numberValue(source.totalBdLeaders ?? source.total_bd_leaders),
+ totalBds: numberValue(source.totalBds ?? source.total_bds),
+ truncated: booleanValue(source.truncated)
+ };
+ });
+}
+
+export function listRooms(query) {
+ return externalRequest(externalOperationPath(API_OPERATIONS.listRooms), { query }).then((data) => normalizePage(data, normalizeRoom));
+}
+
+export function updateRoom(roomId, payload) {
+ return externalRequest(externalOperationPath(API_OPERATIONS.updateRoom, { room_id: roomId }), { body: buildRoomUpdatePayload(payload), method: "PATCH" });
+}
+
+export function buildRoomUpdatePayload(payload) {
+ const regionText = String(payload.visibleRegionId ?? "").trim();
+ const visibleRegionId = regionText ? Number(regionText) : 0;
+ if (!Number.isSafeInteger(visibleRegionId) || visibleRegionId < 0) {
+ throw new Error("可见区域 ID 必须是安全的正整数");
+ }
+ return {
+ coverUrl: String(payload.coverUrl || "").trim(),
+ description: String(payload.description || "").trim(),
+ title: String(payload.title || "").trim(),
+ visibleRegionId
+ };
+}
+
+export function listResources(query) {
+ return externalRequest(externalOperationPath(API_OPERATIONS.listResources), { query }).then((data) => normalizePage(data, normalizeResource));
+}
+
+export function listResourceGrants(query) {
+ return externalRequest(externalOperationPath(API_OPERATIONS.listResourceGrants), { query }).then((data) => normalizePage(data));
+}
+
+export async function lookupGrantTarget(userIdOrPrettyId) {
+ const data = await externalRequest(externalOperationPath(API_OPERATIONS.lookupResourceGrantTarget), { query: { user_id: userIdOrPrettyId } });
+ const source = asRecord(data);
+ const user = asRecord(source.user || source.targetUser || source.target_user);
+ return {
+ id: textValue(source.targetUserId, source.target_user_id, source.userId, source.user_id, user.id, user.userId, user.user_id),
+ prettyId: textValue(source.prettyId, source.pretty_id, user.prettyId, user.pretty_id, user.displayUserId, user.display_user_id),
+ username: textValue(source.username, source.nickname, user.username, user.nickname)
+ };
+}
+
+export function grantResource(payload) {
+ return externalRequest(externalOperationPath(API_OPERATIONS.grantResource), { body: payload, method: "POST" });
+}
+
+export function grantPrettyId(payload) {
+ return externalRequest(externalOperationPath(API_OPERATIONS.grantPrettyId), { body: payload, method: "POST" });
+}
+
+export function listBanners(query) {
+ return externalRequest(externalOperationPath(API_OPERATIONS.listBanners), { query }).then((data) => normalizePage(data, normalizeBanner));
+}
+
+export function createBanner(payload) {
+ return externalRequest(externalOperationPath(API_OPERATIONS.createBanner), { body: buildBannerPayload(payload), method: "POST" });
+}
+
+export function buildBannerPayload(payload) {
+ if (!["h5", "app"].includes(payload.bannerType)) {
+ throw new Error("Banner 类型不正确");
+ }
+ if (!["android", "ios"].includes(payload.platform)) {
+ throw new Error("Banner 平台不正确");
+ }
+ if (!["active", "disabled", "expired"].includes(payload.status)) {
+ throw new Error("Banner 状态不正确");
+ }
+ const displayScopes = [...new Set((payload.displayScopes || []).filter((scope) => ["home", "room", "recharge", "me"].includes(scope)))];
+ if (!displayScopes.length) {
+ throw new Error("请至少选择一个展示范围");
+ }
+ if (!payload.coverUrl) {
+ throw new Error("请上传 Banner 图片");
+ }
+ if (payload.bannerType === "h5") {
+ let target;
+ try {
+ target = new URL(payload.param);
+ } catch {
+ throw new Error("请填写合法的 H5 链接");
+ }
+ if (!["http:", "https:"].includes(target.protocol)) {
+ throw new Error("H5 链接仅支持 http 或 https");
+ }
+ }
+ if (payload.bannerType === "app") {
+ const validation = validatePublicAppJumpParam(payload.param);
+ if (!validation.valid) {
+ throw new Error(validation.message);
+ }
+ }
+ if (displayScopes.includes("room") && !payload.roomSmallImageUrl) {
+ throw new Error("房间范围必须上传房间内小屏图");
+ }
+ const countryCode = String(payload.countryCode || "").trim().toUpperCase();
+ if (countryCode && !/^[A-Z]{2,3}$/.test(countryCode)) {
+ throw new Error("国家码必须是 2-3 位字母");
+ }
+ const regionId = Number(payload.regionId || 0);
+ if (!Number.isSafeInteger(regionId) || regionId < 0) {
+ throw new Error("区域 ID 不正确");
+ }
+ const startsAtMs = Number(payload.startsAtMs || 0);
+ const endsAtMs = Number(payload.endsAtMs || 0);
+ if (startsAtMs > 0 && endsAtMs > 0 && startsAtMs >= endsAtMs) {
+ throw new Error("投放结束时间必须晚于开始时间");
+ }
+
+ return {
+ bannerType: payload.bannerType,
+ countryCode,
+ coverUrl: payload.coverUrl,
+ description: String(payload.description || "").trim(),
+ displayScope: displayScopes[0],
+ displayScopes,
+ endsAtMs,
+ param: String(payload.param || "").trim(),
+ platform: payload.platform,
+ regionId,
+ roomSmallImageUrl: displayScopes.includes("room") ? payload.roomSmallImageUrl : "",
+ sortOrder: Number(payload.sortOrder || 0),
+ startsAtMs,
+ status: payload.status
+ };
+}
+
+export async function uploadExternalImage(file) {
+ const body = new FormData();
+ body.append("file", file);
+ const data = await externalRequest(externalOperationPath(API_OPERATIONS.uploadImage), { body, method: "POST" });
+ const source = asRecord(data);
+ return textValue(source.url, source.fileUrl, source.file_url, asRecord(source.file).url);
+}
+
+export function normalizeUser(item) {
+ const levels = asRecord(item.levels);
+ const wealth = asRecord(levels.wealth);
+ const charm = asRecord(levels.charm);
+ const vip = asRecord(item.vip);
+ const ban = asRecord(item.ban);
+ return {
+ avatar: textValue(item.avatar, item.avatarUrl, item.avatar_url),
+ banned: booleanValue(ban.active, item.banned, item.isBanned, item.is_banned) || ["banned", "disabled"].includes(String(item.status).toLowerCase()),
+ charmLevel: numberValue(item.charmLevel ?? item.charm_level ?? charm.displayLevel ?? charm.display_level ?? charm.level),
+ country: textValue(item.country, item.countryCode, item.country_code),
+ gender: textValue(item.gender),
+ id: entityId(item),
+ prettyId: textValue(item.prettyId, item.pretty_id, item.displayUserId, item.display_user_id, item.shortId, item.short_id),
+ status: textValue(item.status, item.banned ? "banned" : "active"),
+ username: textValue(item.username, item.nickname, item.nickName, item.name),
+ vipLevel: numberValue(item.vipLevel ?? item.vip_level ?? vip.level),
+ wealthLevel: numberValue(item.wealthLevel ?? item.wealth_level ?? wealth.displayLevel ?? wealth.display_level ?? wealth.level)
+ };
+}
+
+export function normalizeBannedUser(item) {
+ // Ban-list rows use the ban-log id as `id`; unban must always target the nested App user id instead.
+ const target = asRecord(item.target || item.user || item.targetUser || item.target_user);
+ const user = normalizeUser(target);
+ return {
+ ...user,
+ banned: true,
+ bannedAtMs: numberValue(item.bannedAtMs ?? item.banned_at_ms ?? item.createdAtMs ?? item.created_at_ms),
+ banLogId: textValue(item.id, item.banLogId, item.ban_log_id),
+ expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
+ id: textValue(target.userId, target.user_id, target.id),
+ prettyId: textValue(target.displayUserId, target.display_user_id, target.prettyId, target.pretty_id),
+ reason: textValue(item.reason)
+ };
+}
+
+export function normalizeOrganization(item) {
+ const user = asRecord(item.user || item.profile || item.manager);
+ const agency = Boolean(item.agencyId ?? item.agency_id);
+ return {
+ avatar: textValue(agency ? item.ownerAvatar : "", agency ? item.owner_avatar : "", item.avatar, item.avatarUrl, item.avatar_url, user.avatar),
+ appCode: textValue(item.appCode, item.app_code),
+ country: textValue(item.regionName, item.region_name, item.country, item.countryCode, item.country_code, user.regionName, user.region_name, user.country),
+ id: textValue(item.agencyId, item.agency_id, item.userId, item.user_id, item.id, entityId(user)),
+ name: textValue(item.name, agency ? item.ownerUsername : "", item.displayName, item.display_name, item.username, user.username, user.name),
+ parentBdUserId: textValue(item.parentBdUserId, item.parent_bd_user_id),
+ prettyId: textValue(agency ? item.ownerDisplayUserId : "", agency ? item.owner_display_user_id : "", item.displayUserId, item.display_user_id, item.prettyId, item.pretty_id, user.displayUserId, user.display_user_id, user.prettyId, user.pretty_id),
+ status: textValue(item.status, item.enabled === false ? "disabled" : "active"),
+ username: textValue(agency ? item.ownerUsername : "", agency ? item.owner_username : "", item.username, user.username, user.nickname),
+ userId: textValue(agency ? item.ownerUserId : "", agency ? item.owner_user_id : "", item.userId, item.user_id, user.userId, user.user_id)
+ };
+}
+
+function normalizeRoom(item) {
+ return {
+ coverUrl: textValue(item.coverUrl, item.cover_url, item.backgroundUrl, item.background_url),
+ description: textValue(item.description, item.introduction),
+ id: entityId(item),
+ ownerName: textValue(item.ownerName, item.owner_name, asRecord(item.owner).username),
+ status: textValue(item.status, item.liveStatus, item.live_status),
+ title: textValue(item.title, item.name, item.roomName, item.room_name),
+ visibleRegionId: textValue(item.visibleRegionId, item.visible_region_id)
+ };
+}
+
+function normalizeResource(item) {
+ return {
+ coverUrl: textValue(item.coverUrl, item.cover_url, item.iconUrl, item.icon_url),
+ id: textValue(item.resourceId, item.resource_id, item.id),
+ name: textValue(item.name, item.resourceName, item.resource_name, item.resourceCode, item.resource_code),
+ status: textValue(item.status),
+ type: textValue(item.resourceType, item.resource_type, item.type)
+ };
+}
+
+function normalizeBanner(item) {
+ return {
+ bannerType: textValue(item.bannerType, item.banner_type, item.type),
+ coverUrl: textValue(item.coverUrl, item.cover_url, item.imageUrl, item.image_url),
+ createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
+ description: textValue(item.description, item.title),
+ id: textValue(item.id, item.bannerId, item.banner_id),
+ platform: textValue(item.platform),
+ sortOrder: numberValue(item.sortOrder ?? item.sort_order),
+ status: textValue(item.status)
+ };
+}
+
+function externalOperationPath(operation, params) {
+ // Generated main-admin paths start with /v1; the external gateway already owns that version prefix in its base URL.
+ return apiEndpointPath(operation, params).replace(/^\/v1(?=\/)/, "");
+}
diff --git a/external-admin/src/api/business.test.js b/external-admin/src/api/business.test.js
new file mode 100644
index 0000000..f7df65e
--- /dev/null
+++ b/external-admin/src/api/business.test.js
@@ -0,0 +1,187 @@
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { buildBannerPayload, buildRoomUpdatePayload, createBanner, grantUserVip, listOrganizations, normalizeBannedUser, normalizeOrganization, normalizeUser, unbanUser } from "./business.js";
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("normalizeBannedUser", () => {
+ test("uses nested target user id instead of ban log id for unban actions", () => {
+ const item = normalizeBannedUser({
+ id: "ban-log-9001",
+ reason: "spam",
+ target: {
+ avatar: "https://cdn.test/avatar.png",
+ displayUserId: "163000",
+ userId: "318705991371722752",
+ username: "Fami User"
+ }
+ });
+
+ expect(item.id).toBe("318705991371722752");
+ expect(item.banLogId).toBe("ban-log-9001");
+ expect(item.prettyId).toBe("163000");
+ expect(item.reason).toBe("spam");
+ });
+
+ test("unban request uses the normalized target user id", async () => {
+ const user = normalizeBannedUser({ id: "ban-log-1", target: { userId: "canonical-user-2" } });
+ const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: {} }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await unbanUser(user.id, { reason: "manual" });
+
+ expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/external/app/users/canonical-user-2/unban");
+ });
+});
+
+describe("room API payload", () => {
+ test("converts region id to int64-compatible number and maps empty to all regions", () => {
+ expect(buildRoomUpdatePayload({ coverUrl: " cover ", description: " desc ", title: " Room ", visibleRegionId: "18" }))
+ .toEqual({ coverUrl: "cover", description: "desc", title: "Room", visibleRegionId: 18 });
+ expect(buildRoomUpdatePayload({ visibleRegionId: "" }).visibleRegionId).toBe(0);
+ expect(() => buildRoomUpdatePayload({ visibleRegionId: "1.5" })).toThrow("可见区域 ID");
+ });
+});
+
+describe("real external DTO normalization", () => {
+ test("reads nested levels, vip and ban state from the App user DTO", () => {
+ const user = normalizeUser({
+ ban: { active: true },
+ levels: { charm: { displayLevel: 8 }, wealth: { displayLevel: 12 } },
+ userId: "user-1",
+ vip: { level: 4 }
+ });
+ expect(user).toEqual(expect.objectContaining({ banned: true, charmLevel: 8, vipLevel: 4, wealthLevel: 12 }));
+ });
+
+ test("normalizes agency owner fields and ordinary member fields", () => {
+ expect(normalizeOrganization({
+ agencyId: "agency-1",
+ name: "Fami Guild",
+ ownerAvatar: "owner.png",
+ ownerDisplayUserId: "163000",
+ ownerUserId: "owner-9",
+ ownerUsername: "Guild Owner",
+ regionName: "Middle East"
+ })).toEqual(expect.objectContaining({
+ avatar: "owner.png",
+ id: "agency-1",
+ name: "Fami Guild",
+ prettyId: "163000",
+ userId: "owner-9",
+ username: "Guild Owner"
+ }));
+ expect(normalizeOrganization({ displayUserId: "9988", regionName: "SEA", userId: "bd-1", username: "BD One" }))
+ .toEqual(expect.objectContaining({ id: "bd-1", name: "BD One", prettyId: "9988" }));
+ });
+
+ test("maps BD Manager, Super Admin and team to their real scoped endpoints", async () => {
+ const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { items: [], total: 0 } }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await listOrganizations("bd-leaders", { page: 1 });
+ await listOrganizations("super-admins", { page: 1 });
+ await listOrganizations("team", { page: 1 });
+
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
+ "/api/v1/external/admin/managers?page=1",
+ "/api/v1/external/admin/bd-leaders?page=1&status=active",
+ "/api/v1/external/admin/my-team/agencies?page=1"
+ ]);
+ });
+
+ test("preserves scoped team totals and truncation metadata", async () => {
+ vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({
+ code: 0,
+ data: {
+ agencies: [{ agencyId: "agency-9", ownerUserId: "owner-9", parentBdUserId: "bd-7", status: "active" }],
+ total: 1,
+ totalBdLeaders: 2,
+ totalBds: 7,
+ truncated: true
+ }
+ })));
+
+ const team = await listOrganizations("team", { page: 1 });
+
+ expect(team).toEqual(expect.objectContaining({ total: 1, totalBdLeaders: 2, totalBds: 7, truncated: true }));
+ expect(team.items[0]).toEqual(expect.objectContaining({ id: "agency-9", parentBdUserId: "bd-7", userId: "owner-9" }));
+ });
+});
+
+describe("banner API payload", () => {
+ test("uses only the real appconfig banner enums and room image contract", () => {
+ expect(buildBannerPayload({
+ bannerType: "h5",
+ countryCode: "sa",
+ coverUrl: "https://cdn.test/banner.webp",
+ description: "Campaign",
+ displayScopes: ["home", "room"],
+ param: "https://fami.test/campaign",
+ platform: "android",
+ regionId: 8,
+ roomSmallImageUrl: "https://cdn.test/room.webp",
+ sortOrder: 2,
+ status: "active"
+ })).toEqual(expect.objectContaining({
+ bannerType: "h5",
+ countryCode: "SA",
+ displayScope: "home",
+ displayScopes: ["home", "room"],
+ platform: "android",
+ roomSmallImageUrl: "https://cdn.test/room.webp",
+ status: "active"
+ }));
+ expect(() => buildBannerPayload({ bannerType: "link", coverUrl: "x", displayScopes: ["home"], platform: "all", status: "enabled" }))
+ .toThrow("Banner 类型不正确");
+ });
+
+ test("create API sends the normalized appconfig payload", async () => {
+ const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: {} }));
+ vi.stubGlobal("fetch", fetchMock);
+ await createBanner({
+ bannerType: "app",
+ coverUrl: "https://cdn.test/banner.webp",
+ displayScopes: ["me"],
+ param: JSON.stringify({ type: "wallet" }),
+ platform: "ios",
+ status: "disabled"
+ });
+
+ expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/external/admin/app-config/banners");
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual(expect.objectContaining({
+ bannerType: "app",
+ displayScope: "me",
+ displayScopes: ["me"],
+ platform: "ios",
+ status: "disabled"
+ }));
+ });
+});
+
+describe("VIP grant routing", () => {
+ test("routes trial-card VIP grants to vipconfig and never sends track=vip to app users", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse({ code: 0, data: { config: { grantMode: "trial_card", status: "active" } } }))
+ .mockResolvedValueOnce(jsonResponse({ code: 0, data: { levels: [{ level: 5 }] } }))
+ .mockResolvedValueOnce(jsonResponse({ code: 0, data: {} }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await grantUserVip("user-9", { commandId: "vip-test", durationMs: 604800000, level: 5, reason: "manual" });
+
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
+ "/api/v1/external/admin/activity/vip-program",
+ "/api/v1/external/admin/activity/vip-levels",
+ "/api/v1/external/admin/activity/vip-trial-card-grants"
+ ]);
+ const payload = JSON.parse(fetchMock.mock.calls[2][1].body);
+ expect(payload).toEqual(expect.objectContaining({ durationMs: 604800000, level: 5, targetUserId: "user-9" }));
+ expect(payload).not.toHaveProperty("track");
+ });
+});
+
+function jsonResponse(body, status = 200) {
+ return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status });
+}
diff --git a/external-admin/src/api/client.js b/external-admin/src/api/client.js
new file mode 100644
index 0000000..507b791
--- /dev/null
+++ b/external-admin/src/api/client.js
@@ -0,0 +1,149 @@
+const EXTERNAL_API_BASE = "/api/v1/external";
+const CSRF_COOKIE_NAME = "hyapp_external_csrf";
+const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
+
+let unauthorizedHandler = null;
+let inMemoryCsrfToken = "";
+
+export class ExternalApiError extends Error {
+ constructor(message, { code = "", status = 0, details = null } = {}) {
+ super(message);
+ this.name = "ExternalApiError";
+ this.code = code;
+ this.status = status;
+ this.details = details;
+ }
+}
+
+export function setExternalUnauthorizedHandler(handler) {
+ unauthorizedHandler = typeof handler === "function" ? handler : null;
+ return () => {
+ if (unauthorizedHandler === handler) {
+ unauthorizedHandler = null;
+ }
+ };
+}
+
+export function rememberExternalCsrfToken(token) {
+ // Cookie remains the source of truth. The memory copy only covers deployments where Cookie Path prevents page JavaScript from reading it.
+ inMemoryCsrfToken = String(token || "").trim();
+}
+
+export function readExternalCsrfToken(cookieText = globalThis.document?.cookie || "") {
+ const prefix = `${CSRF_COOKIE_NAME}=`;
+ const cookieValue = String(cookieText)
+ .split(";")
+ .map((part) => part.trim())
+ .find((part) => part.startsWith(prefix));
+
+ if (!cookieValue) {
+ return inMemoryCsrfToken;
+ }
+
+ try {
+ return decodeURIComponent(cookieValue.slice(prefix.length));
+ } catch {
+ return cookieValue.slice(prefix.length);
+ }
+}
+
+export async function externalRequest(path, options = {}) {
+ const method = String(options.method || "GET").toUpperCase();
+ const headers = new Headers(options.headers || {});
+ const body = createBody(options.body, headers);
+
+ if (!SAFE_METHODS.has(method)) {
+ const csrfToken = readExternalCsrfToken();
+ if (csrfToken) {
+ headers.set("X-CSRF-Token", csrfToken);
+ }
+ }
+
+ const response = await fetch(buildUrl(path, options.query), {
+ body,
+ credentials: "include",
+ headers,
+ method,
+ signal: options.signal
+ });
+ const payload = await readPayload(response);
+
+ if (response.status === 401 && options.notifyUnauthorized !== false) {
+ unauthorizedHandler?.();
+ }
+
+ if (!response.ok) {
+ throw apiError(payload, response.status, response.statusText || "请求失败");
+ }
+
+ if (isEnvelopeFailure(payload)) {
+ throw apiError(payload, response.status, "请求失败");
+ }
+
+ const data = unwrapPayload(payload);
+ if (data && typeof data === "object" && "csrfToken" in data) {
+ rememberExternalCsrfToken(data.csrfToken);
+ }
+ return data;
+}
+
+function buildUrl(path, query) {
+ const normalizedPath = String(path || "").startsWith("/") ? path : `/${path}`;
+ const params = new URLSearchParams();
+
+ Object.entries(query || {}).forEach(([key, value]) => {
+ if (value === undefined || value === null || value === "") {
+ return;
+ }
+ (Array.isArray(value) ? value : [value]).forEach((item) => params.append(key, String(item)));
+ });
+
+ const search = params.toString();
+ return `${EXTERNAL_API_BASE}${normalizedPath}${search ? `?${search}` : ""}`;
+}
+
+function createBody(body, headers) {
+ if (body === undefined || body === null) {
+ return undefined;
+ }
+ if (body instanceof FormData || typeof body === "string" || body instanceof Blob) {
+ return body;
+ }
+ headers.set("Content-Type", "application/json");
+ return JSON.stringify(body);
+}
+
+async function readPayload(response) {
+ if (response.status === 204) {
+ return null;
+ }
+ const contentType = response.headers.get("content-type") || "";
+ if (contentType.includes("application/json")) {
+ return response.json();
+ }
+ const text = await response.text();
+ return text ? { message: text } : null;
+}
+
+function isEnvelopeFailure(payload) {
+ if (!payload || typeof payload !== "object" || !("code" in payload)) {
+ return false;
+ }
+ return ![0, 200, "0", "200", "OK", "SUCCESS"].includes(payload.code);
+}
+
+function unwrapPayload(payload) {
+ if (!payload || typeof payload !== "object") {
+ return payload;
+ }
+ return "data" in payload ? payload.data : payload;
+}
+
+function apiError(payload, status, fallback) {
+ const source = payload && typeof payload === "object" ? payload : {};
+ return new ExternalApiError(source.message || source.msg || fallback, {
+ code: source.code,
+ details: source.details || source.errors || null,
+ status
+ });
+}
diff --git a/external-admin/src/api/client.test.js b/external-admin/src/api/client.test.js
new file mode 100644
index 0000000..93fd1bb
--- /dev/null
+++ b/external-admin/src/api/client.test.js
@@ -0,0 +1,74 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import {
+ ExternalApiError,
+ externalRequest,
+ readExternalCsrfToken,
+ rememberExternalCsrfToken,
+ setExternalUnauthorizedHandler
+} from "./client.js";
+
+describe("externalRequest", () => {
+ beforeEach(() => {
+ rememberExternalCsrfToken("");
+ document.cookie = "hyapp_external_csrf=; Max-Age=0; Path=/";
+ });
+
+ afterEach(() => {
+ setExternalUnauthorizedHandler(null);
+ vi.unstubAllGlobals();
+ });
+
+ test("uses only the isolated cookie session and external API base", async () => {
+ const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { items: [] } }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await externalRequest("/app/users", { query: { keyword: "163000", page: 1 } });
+
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/v1/external/app/users?keyword=163000&page=1");
+ expect(init.credentials).toBe("include");
+ expect(init.headers.get("Authorization")).toBeNull();
+ expect(init.headers.get("X-App-Code")).toBeNull();
+ expect(init.headers.get("X-CSRF-Token")).toBeNull();
+ });
+
+ test("adds CSRF to writes from the dedicated cookie", async () => {
+ document.cookie = "hyapp_external_csrf=csrf%20token; Path=/";
+ const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { ok: true } }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await externalRequest("/auth/change-password", { body: { currentPassword: "old", newPassword: "new" }, method: "POST" });
+
+ const [, init] = fetchMock.mock.calls[0];
+ expect(init.headers.get("X-CSRF-Token")).toBe("csrf token");
+ expect(JSON.parse(init.body)).toEqual({ currentPassword: "old", newPassword: "new" });
+ });
+
+ test("keeps csrfToken in memory when the API-scoped cookie is unreadable", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse({ code: 0, data: { csrfToken: "memory-csrf", user: { username: "operator" } } }))
+ .mockResolvedValueOnce(jsonResponse({ code: 0, data: {} }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await externalRequest("/auth/me");
+ await externalRequest("/auth/logout", { method: "POST" });
+
+ expect(fetchMock.mock.calls[1][1].headers.get("X-CSRF-Token")).toBe("memory-csrf");
+ expect(readExternalCsrfToken("")).toBe("memory-csrf");
+ });
+
+ test("invalidates the external session on protected 401", async () => {
+ const handler = vi.fn();
+ setExternalUnauthorizedHandler(handler);
+ vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ code: "UNAUTHORIZED", message: "请登录" }, 401)));
+
+ await expect(externalRequest("/admin/rooms")).rejects.toEqual(expect.objectContaining({ status: 401 }));
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(new ExternalApiError("x")).toBeInstanceOf(Error);
+ });
+});
+
+function jsonResponse(body, status = 200) {
+ return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status });
+}
diff --git a/external-admin/src/api/normalizers.js b/external-admin/src/api/normalizers.js
new file mode 100644
index 0000000..c03506f
--- /dev/null
+++ b/external-admin/src/api/normalizers.js
@@ -0,0 +1,59 @@
+export function normalizePage(data, mapItem = normalizeRecord) {
+ const source = asRecord(data);
+ const rawItems = Array.isArray(data)
+ ? data
+ : arrayValue(source.items, source.agencies, source.list, source.records, source.content, source.rows);
+ const page = positiveNumber(source.page ?? source.page_no ?? source.pageNo, 1);
+ const pageSize = positiveNumber(source.pageSize ?? source.page_size ?? source.size, rawItems.length || 20);
+
+ return {
+ items: rawItems.map((item) => mapItem(asRecord(item))),
+ page,
+ pageSize,
+ total: nonNegativeNumber(source.total ?? source.total_count ?? source.totalCount, rawItems.length)
+ };
+}
+
+export function normalizeRecord(value) {
+ return asRecord(value);
+}
+
+export function asRecord(value) {
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
+}
+
+export function arrayValue(...values) {
+ return values.find(Array.isArray) || [];
+}
+
+export function textValue(...values) {
+ const value = values.find((item) => item !== undefined && item !== null && item !== "");
+ return value === undefined ? "" : String(value);
+}
+
+export function numberValue(value, fallback = 0) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+export function booleanValue(...values) {
+ const value = values.find((item) => item !== undefined && item !== null);
+ if (typeof value === "string") {
+ return ["1", "true", "enabled", "active", "yes"].includes(value.toLowerCase());
+ }
+ return Boolean(value);
+}
+
+export function entityId(item) {
+ return textValue(item.id, item.userId, item.user_id, item.roomId, item.room_id, item.resourceId, item.resource_id);
+}
+
+function positiveNumber(value, fallback) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
+}
+
+function nonNegativeNumber(value, fallback) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
+}
diff --git a/external-admin/src/auth/ExternalAuthProvider.jsx b/external-admin/src/auth/ExternalAuthProvider.jsx
new file mode 100644
index 0000000..b7de90d
--- /dev/null
+++ b/external-admin/src/auth/ExternalAuthProvider.jsx
@@ -0,0 +1,73 @@
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
+import { changeExternalPassword, getExternalSession, loginExternal, logoutExternal } from "../api/auth.js";
+import { ExternalApiError, setExternalUnauthorizedHandler } from "../api/client.js";
+
+const ExternalAuthContext = createContext(null);
+
+export function ExternalAuthProvider({ children }) {
+ const [session, setSession] = useState(null);
+ const [initializing, setInitializing] = useState(true);
+ const [initializationError, setInitializationError] = useState("");
+
+ const refresh = useCallback(async () => {
+ setInitializationError("");
+ try {
+ const nextSession = await getExternalSession();
+ setSession(nextSession);
+ return nextSession;
+ } catch (error) {
+ setSession(null);
+ if (!(error instanceof ExternalApiError && error.status === 401)) {
+ setInitializationError(error.message || "会话校验失败");
+ }
+ return null;
+ } finally {
+ setInitializing(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ useEffect(() => {
+ // Any protected request returning 401 invalidates the entire external session; it never mutates the main-admin session.
+ return setExternalUnauthorizedHandler(() => setSession(null));
+ }, []);
+
+ const login = useCallback(async (credentials) => {
+ const nextSession = await loginExternal(credentials);
+ setSession(nextSession);
+ return nextSession;
+ }, []);
+
+ const logout = useCallback(async () => {
+ try {
+ await logoutExternal();
+ } finally {
+ setSession(null);
+ }
+ }, []);
+
+ const changePassword = useCallback(async (payload) => {
+ const responseSession = await changeExternalPassword(payload);
+ const nextSession = responseSession?.appCode ? responseSession : await getExternalSession();
+ setSession({ ...nextSession, passwordChangeRequired: false });
+ return nextSession;
+ }, []);
+
+ const value = useMemo(
+ () => ({ changePassword, initializationError, initializing, login, logout, refresh, session }),
+ [changePassword, initializationError, initializing, login, logout, refresh, session]
+ );
+
+ return {children};
+}
+
+export function useExternalAuth() {
+ const context = useContext(ExternalAuthContext);
+ if (!context) {
+ throw new Error("useExternalAuth must be used inside ExternalAuthProvider");
+ }
+ return context;
+}
diff --git a/external-admin/src/auth/RouteGuards.jsx b/external-admin/src/auth/RouteGuards.jsx
new file mode 100644
index 0000000..e47933a
--- /dev/null
+++ b/external-admin/src/auth/RouteGuards.jsx
@@ -0,0 +1,58 @@
+import Alert from "@mui/material/Alert";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import CircularProgress from "@mui/material/CircularProgress";
+import Typography from "@mui/material/Typography";
+import { Navigate, Outlet, useLocation } from "react-router-dom";
+import { hasAnyExternalCapability } from "../config/capabilities.js";
+import { useExternalAuth } from "./ExternalAuthProvider.jsx";
+
+export function RequireExternalAuth() {
+ const { initializationError, initializing, refresh, session } = useExternalAuth();
+ const location = useLocation();
+
+ if (initializing) {
+ return (
+
+
+ 正在校验外管会话
+
+ );
+ }
+
+ if (!session) {
+ if (initializationError) {
+ return (
+
+ {initializationError}
+
+
+ );
+ }
+ return ;
+ }
+
+ return ;
+}
+
+export function RequirePasswordChanged() {
+ const { session } = useExternalAuth();
+ return session?.passwordChangeRequired ? : ;
+}
+
+export function RequireExternalCapability({ anyOf }) {
+ const { session } = useExternalAuth();
+
+ if (hasAnyExternalCapability(session, anyOf)) {
+ return ;
+ }
+
+ return (
+
+ 当前外管账号没有访问此功能的权限。
+
+
+ );
+}
diff --git a/external-admin/src/components/BannerCreateDialog.jsx b/external-admin/src/components/BannerCreateDialog.jsx
new file mode 100644
index 0000000..98ee4ad
--- /dev/null
+++ b/external-admin/src/components/BannerCreateDialog.jsx
@@ -0,0 +1,110 @@
+import Button from "@mui/material/Button";
+import Checkbox from "@mui/material/Checkbox";
+import Dialog from "@mui/material/Dialog";
+import DialogActions from "@mui/material/DialogActions";
+import DialogContent from "@mui/material/DialogContent";
+import DialogTitle from "@mui/material/DialogTitle";
+import ListItemText from "@mui/material/ListItemText";
+import MenuItem from "@mui/material/MenuItem";
+import Stack from "@mui/material/Stack";
+import TextField from "@mui/material/TextField";
+import { useEffect, useState } from "react";
+import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx";
+import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
+import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx";
+
+const displayScopeOptions = [
+ ["home", "首页"],
+ ["room", "房间"],
+ ["recharge", "充值"],
+ ["me", "我的"]
+];
+
+export function BannerCreateDialog({ loading, onClose, onSubmit, open }) {
+ const [form, setForm] = useState(initialForm);
+ useEffect(() => setForm(initialForm), [open]);
+
+ const submit = (event) => {
+ event.preventDefault();
+ onSubmit({
+ ...form,
+ regionId: Number(form.regionId || 0),
+ roomSmallImageUrl: form.displayScopes.includes("room") ? form.roomSmallImageUrl : "",
+ sortOrder: Number(form.sortOrder || 0)
+ });
+ };
+
+ return (
+
+ );
+}
+
+const initialForm = {
+ bannerType: "h5",
+ countryCode: "",
+ coverUrl: "",
+ description: "",
+ displayScopes: ["home"],
+ endsAtMs: 0,
+ param: "",
+ platform: "android",
+ regionId: "",
+ roomSmallImageUrl: "",
+ sortOrder: "0",
+ startsAtMs: 0,
+ status: "active"
+};
diff --git a/external-admin/src/components/BannerCreateDialog.test.jsx b/external-admin/src/components/BannerCreateDialog.test.jsx
new file mode 100644
index 0000000..54b4fd8
--- /dev/null
+++ b/external-admin/src/components/BannerCreateDialog.test.jsx
@@ -0,0 +1,20 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, test, vi } from "vitest";
+import { BannerCreateDialog } from "./BannerCreateDialog.jsx";
+
+describe("BannerCreateDialog", () => {
+ test("uses the shared structured app jump field and unified time range picker", async () => {
+ const user = userEvent.setup();
+ render();
+
+ expect(screen.getByRole("textbox", { name: "参数(H5 链接)" })).toBeInTheDocument();
+ expect(screen.getByText("投放时间")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("combobox", { name: "跳转类型" }));
+ await user.click(await screen.findByRole("option", { name: "APP" }));
+
+ expect(screen.getByRole("combobox", { name: "APP 目标" })).toBeInTheDocument();
+ expect(screen.getByRole("textbox", { name: "参数(APP 公共跳转 JSON)" })).toHaveValue(JSON.stringify({ type: "wallet" }));
+ });
+});
diff --git a/external-admin/src/components/GrantDialogs.jsx b/external-admin/src/components/GrantDialogs.jsx
new file mode 100644
index 0000000..ab6d34d
--- /dev/null
+++ b/external-admin/src/components/GrantDialogs.jsx
@@ -0,0 +1,60 @@
+import Alert from "@mui/material/Alert";
+import Button from "@mui/material/Button";
+import Dialog from "@mui/material/Dialog";
+import DialogActions from "@mui/material/DialogActions";
+import DialogContent from "@mui/material/DialogContent";
+import DialogTitle from "@mui/material/DialogTitle";
+import MenuItem from "@mui/material/MenuItem";
+import Stack from "@mui/material/Stack";
+import TextField from "@mui/material/TextField";
+import { useEffect, useState } from "react";
+
+export function ResourceGrantDialog({ loading, onClose, onSubmit, open, resources }) {
+ const [form, setForm] = useState(resourceGrantInitial);
+ useEffect(() => setForm(resourceGrantInitial), [open]);
+ return (
+ onSubmit(form)}>
+
+ setForm({ ...form, target: event.target.value.trim() })} />
+ setForm({ ...form, resourceId: event.target.value })}>
+ {resources.map((item) => )}
+
+ setForm({ ...form, quantity: event.target.value })} />
+ setForm({ ...form, durationDays: event.target.value })} />
+ setForm({ ...form, reason: event.target.value })} />
+
+ );
+}
+
+export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) {
+ const [form, setForm] = useState(prettyGrantInitial);
+ useEffect(() => setForm(prettyGrantInitial), [open]);
+ return (
+ onSubmit(form)}>
+
+ setForm({ ...form, target: event.target.value.trim() })} />
+ setForm({ ...form, prettyId: event.target.value.trim() })} />
+ setForm({ ...form, durationDays: event.target.value })} />
+ setForm({ ...form, reason: event.target.value })} />
+
+ );
+}
+
+function TargetNotice() {
+ return 提交前会校验当前 App 内的用户,并使用服务端返回的用户 ID 执行发放。;
+}
+
+function GrantDialog({ children, loading, onClose, onSubmit, open, title }) {
+ return (
+
+ );
+}
+
+const resourceGrantInitial = { durationDays: "30", quantity: "1", reason: "", resourceId: "", target: "" };
+const prettyGrantInitial = { durationDays: "0", prettyId: "", reason: "", target: "" };
diff --git a/external-admin/src/components/RoomEditDialog.jsx b/external-admin/src/components/RoomEditDialog.jsx
new file mode 100644
index 0000000..da60be6
--- /dev/null
+++ b/external-admin/src/components/RoomEditDialog.jsx
@@ -0,0 +1,30 @@
+import Button from "@mui/material/Button";
+import Dialog from "@mui/material/Dialog";
+import DialogActions from "@mui/material/DialogActions";
+import DialogContent from "@mui/material/DialogContent";
+import DialogTitle from "@mui/material/DialogTitle";
+import Stack from "@mui/material/Stack";
+import TextField from "@mui/material/TextField";
+import { useEffect, useState } from "react";
+import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx";
+
+export function RoomEditDialog({ loading, onClose, onSubmit, open, room }) {
+ const [form, setForm] = useState({ coverUrl: "", description: "", title: "", visibleRegionId: "" });
+ useEffect(() => {
+ setForm({ coverUrl: room?.coverUrl || "", description: room?.description || "", title: room?.title || "", visibleRegionId: room?.visibleRegionId || "" });
+ }, [room]);
+ return (
+
+ );
+}
diff --git a/external-admin/src/components/UserDialogs.jsx b/external-admin/src/components/UserDialogs.jsx
new file mode 100644
index 0000000..a227b14
--- /dev/null
+++ b/external-admin/src/components/UserDialogs.jsx
@@ -0,0 +1,69 @@
+import Button from "@mui/material/Button";
+import Dialog from "@mui/material/Dialog";
+import DialogActions from "@mui/material/DialogActions";
+import DialogContent from "@mui/material/DialogContent";
+import DialogTitle from "@mui/material/DialogTitle";
+import MenuItem from "@mui/material/MenuItem";
+import Stack from "@mui/material/Stack";
+import TextField from "@mui/material/TextField";
+import { useEffect, useState } from "react";
+import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx";
+
+export function UserEditDialog({ loading, onClose, onSubmit, open, user }) {
+ const [form, setForm] = useState(emptyEditForm);
+ useEffect(() => {
+ setForm({ avatar: user?.avatar || "", country: user?.country || "", gender: user?.gender || "", username: user?.username || "" });
+ }, [user]);
+ return (
+ onSubmit(form)}>
+ setForm({ ...form, avatar })} />
+ setForm({ ...form, username: event.target.value })} />
+ setForm({ ...form, gender: event.target.value })}>
+
+
+ setForm({ ...form, country: event.target.value.trim().toUpperCase() })} />
+
+ );
+}
+
+export function UserBanDialog({ loading, onClose, onSubmit, open, user }) {
+ const [form, setForm] = useState({ days: "7", reason: "" });
+ useEffect(() => setForm({ days: "7", reason: "" }), [user]);
+ return (
+ onSubmit(form)}>
+ setForm({ ...form, days: event.target.value })} />
+ setForm({ ...form, reason: event.target.value })} />
+
+ );
+}
+
+export function UserLevelDialog({ loading, onClose, onSubmit, open, user }) {
+ const [form, setForm] = useState({ days: "30", level: "", reason: "", track: "wealth" });
+ useEffect(() => setForm({ days: "30", level: "", reason: "", track: "wealth" }), [user]);
+ return (
+ onSubmit(form)}>
+ setForm({ ...form, track: event.target.value })}>
+ setForm({ ...form, level: event.target.value })} />
+ setForm({ ...form, days: event.target.value })} />
+ setForm({ ...form, reason: event.target.value })} />
+
+ );
+}
+
+function BaseDialog({ children, loading, onClose, onSubmit, open, title }) {
+ const handleSubmit = (event) => {
+ event.preventDefault();
+ onSubmit();
+ };
+ return (
+
+ );
+}
+
+const emptyEditForm = { avatar: "", country: "", gender: "", username: "" };
diff --git a/external-admin/src/config/capabilities.js b/external-admin/src/config/capabilities.js
new file mode 100644
index 0000000..a346f9f
--- /dev/null
+++ b/external-admin/src/config/capabilities.js
@@ -0,0 +1,31 @@
+export const EXTERNAL_CAPABILITIES = [
+ { code: "user:list", label: "用户列表", path: "/users" },
+ { code: "user:update", label: "用户信息编辑", path: "/users" },
+ { code: "user-ban:list", label: "账号封禁列表", path: "/bans" },
+ { code: "user:ban", label: "执行封禁", path: "/users" },
+ { code: "user:unban", label: "解除封禁", path: "/bans" },
+ { code: "host:list", label: "主播列表", path: "/organization/hosts" },
+ { code: "agency:list", label: "公会列表", path: "/organization/agencies" },
+ { code: "bd:list", label: "BD 列表", path: "/organization/bds" },
+ { code: "bd-manager:list", label: "BD Manager 列表", path: "/organization/bd-leaders" },
+ { code: "super-admin:list", label: "Super Admin 列表", path: "/organization/super-admins" },
+ { code: "room:list", label: "房间管理", path: "/rooms" },
+ { code: "room:update", label: "房间编辑", path: "/rooms" },
+ { code: "privilege:list", label: "用户特权道具列表", path: "/grants" },
+ { code: "privilege:grant", label: "特权道具发放", path: "/grants" },
+ { code: "banner:create", label: "Banner 创建", path: "/banners" },
+ { code: "pretty-id:grant", label: "靓号下发", path: "/grants" },
+ { code: "user-title:grant", label: "用户称号下发", path: "/grants" },
+ { code: "room-background:grant", label: "房间背景图下发", path: "/grants" },
+ { code: "user-level:grant", label: "财富/VIP等级下发", path: "/users" },
+ { code: "team:view", label: "我的团队", path: "/organization/team" }
+];
+
+export function hasExternalCapability(session, code) {
+ const capabilities = session?.capabilities || [];
+ return capabilities.includes("*") || capabilities.includes("external-admin:*") || capabilities.includes(code);
+}
+
+export function hasAnyExternalCapability(session, codes) {
+ return codes.some((code) => hasExternalCapability(session, code));
+}
diff --git a/external-admin/src/config/entry-routing.test.js b/external-admin/src/config/entry-routing.test.js
new file mode 100644
index 0000000..b7a3291
--- /dev/null
+++ b/external-admin/src/config/entry-routing.test.js
@@ -0,0 +1,43 @@
+import { describe, expect, test, vi } from "vitest";
+import { redirectSubsystemEntry } from "../../../vite.config.js";
+
+describe("external-admin Vite entry routing", () => {
+ test("redirects the bare subsystem path and preserves the app query", () => {
+ const req = { url: "/external-admin?app=fami" };
+ const res = responseStub();
+ const next = vi.fn();
+
+ redirectSubsystemEntry(req, res, next);
+
+ expect(res.statusCode).toBe(301);
+ expect(res.setHeader).toHaveBeenCalledWith("Location", "/external-admin/?app=fami");
+ expect(res.end).toHaveBeenCalled();
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ test("rewrites a BrowserRouter deep link to the isolated HTML document", () => {
+ const req = { url: "/external-admin/organization/hosts?from=test" };
+ const res = responseStub();
+ const next = vi.fn();
+
+ redirectSubsystemEntry(req, res, next);
+
+ expect(req.url).toBe("/external-admin/index.html?from=test");
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(res.end).not.toHaveBeenCalled();
+ });
+
+ test("does not rewrite source module requests", () => {
+ const req = { url: "/external-admin/src/main.jsx" };
+ const next = vi.fn();
+
+ redirectSubsystemEntry(req, responseStub(), next);
+
+ expect(req.url).toBe("/external-admin/src/main.jsx");
+ expect(next).toHaveBeenCalledTimes(1);
+ });
+});
+
+function responseStub() {
+ return { end: vi.fn(), setHeader: vi.fn(), statusCode: 200 };
+}
diff --git a/external-admin/src/layout/ExternalAdminLayout.jsx b/external-admin/src/layout/ExternalAdminLayout.jsx
new file mode 100644
index 0000000..59a5787
--- /dev/null
+++ b/external-admin/src/layout/ExternalAdminLayout.jsx
@@ -0,0 +1,128 @@
+import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined";
+import BadgeOutlined from "@mui/icons-material/BadgeOutlined";
+import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
+import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
+import ImageOutlined from "@mui/icons-material/ImageOutlined";
+import LogoutOutlined from "@mui/icons-material/LogoutOutlined";
+import MenuOutlined from "@mui/icons-material/MenuOutlined";
+import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
+import PeopleOutlineOutlined from "@mui/icons-material/PeopleOutlineOutlined";
+import PersonOffOutlined from "@mui/icons-material/PersonOffOutlined";
+import RedeemOutlined from "@mui/icons-material/RedeemOutlined";
+import SecurityOutlined from "@mui/icons-material/SecurityOutlined";
+import AppBar from "@mui/material/AppBar";
+import Avatar from "@mui/material/Avatar";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import Divider from "@mui/material/Divider";
+import Drawer from "@mui/material/Drawer";
+import IconButton from "@mui/material/IconButton";
+import List from "@mui/material/List";
+import ListItemButton from "@mui/material/ListItemButton";
+import ListItemIcon from "@mui/material/ListItemIcon";
+import ListItemText from "@mui/material/ListItemText";
+import Stack from "@mui/material/Stack";
+import Toolbar from "@mui/material/Toolbar";
+import Tooltip from "@mui/material/Tooltip";
+import Typography from "@mui/material/Typography";
+import useMediaQuery from "@mui/material/useMediaQuery";
+import { useTheme } from "@mui/material/styles";
+import { useMemo, useState } from "react";
+import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
+import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
+import { hasAnyExternalCapability } from "../config/capabilities.js";
+
+const drawerWidth = 248;
+const navigation = [
+ { icon: DashboardOutlined, label: "权限总览", path: "/overview" },
+ { capabilities: ["user:list", "user:update", "user:ban", "user-level:grant"], icon: PeopleOutlineOutlined, label: "用户管理", path: "/users" },
+ { capabilities: ["user-ban:list", "user:unban"], icon: PersonOffOutlined, label: "封禁管理", path: "/bans" },
+ { capabilities: ["host:list"], icon: BadgeOutlined, label: "主播列表", path: "/organization/hosts" },
+ { capabilities: ["agency:list"], icon: ApartmentOutlined, label: "公会列表", path: "/organization/agencies" },
+ { capabilities: ["bd:list"], icon: GroupsOutlined, label: "BD 列表", path: "/organization/bds" },
+ { capabilities: ["bd-manager:list"], icon: SecurityOutlined, label: "BD Manager", path: "/organization/bd-leaders" },
+ { capabilities: ["super-admin:list"], icon: SecurityOutlined, label: "Super Admin", path: "/organization/super-admins" },
+ { capabilities: ["room:list", "room:update"], icon: MeetingRoomOutlined, label: "房间管理", path: "/rooms" },
+ { capabilities: ["privilege:list", "privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"], icon: RedeemOutlined, label: "资源与靓号", path: "/grants" },
+ { capabilities: ["banner:create"], icon: ImageOutlined, label: "Banner 管理", path: "/banners" },
+ { capabilities: ["team:view"], icon: GroupsOutlined, label: "我的团队", path: "/organization/team" }
+];
+
+export function ExternalAdminLayout() {
+ const theme = useTheme();
+ const mobile = useMediaQuery(theme.breakpoints.down("md"));
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const { logout, session } = useExternalAuth();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const visibleNavigation = useMemo(
+ () => navigation.filter((item) => !item.capabilities || hasAnyExternalCapability(session, item.capabilities)),
+ [session]
+ );
+ const current = [...visibleNavigation].reverse().find((item) => location.pathname.startsWith(item.path));
+
+ const handleLogout = async () => {
+ await logout();
+ navigate("/login", { replace: true });
+ };
+
+ const drawer = (
+
+
+ HY
+
+ HYApp 外管
+ {session?.appName || session?.appCode}
+
+
+
+
+ {visibleNavigation.map((item) => {
+ const Icon = item.icon;
+ return (
+ setDrawerOpen(false)}
+ >
+
+
+
+ );
+ })}
+
+
+ );
+
+ return (
+
+
+
+ {mobile ? setDrawerOpen(true)}> : null}
+ {current?.label || "外管后台"}
+
+
+ {String(session?.account || "管").slice(0, 1).toUpperCase()}
+ {!mobile ? (
+
+ {session?.account}
+ {session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""}
+
+ ) : null}
+
+ }>{mobile ? "" : "退出"}
+
+
+
+
+ setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer}
+ {drawer}
+
+
+
+
+
+ );
+}
diff --git a/external-admin/src/main.jsx b/external-admin/src/main.jsx
new file mode 100644
index 0000000..353822a
--- /dev/null
+++ b/external-admin/src/main.jsx
@@ -0,0 +1,27 @@
+import CssBaseline from "@mui/material/CssBaseline";
+import { ThemeProvider } from "@mui/material/styles";
+import React from "react";
+import { createRoot } from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
+import { theme } from "@/theme.js";
+import { ExternalAdminApp } from "./ExternalAdminApp.jsx";
+import { ExternalAuthProvider } from "./auth/ExternalAuthProvider.jsx";
+import "@/styles/tokens.css";
+import "@/styles/shared-ui.css";
+import "./styles/index.css";
+
+createRoot(document.getElementById("external-admin-root")).render(
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/external-admin/src/pages/BannersPage.jsx b/external-admin/src/pages/BannersPage.jsx
new file mode 100644
index 0000000..5c0144d
--- /dev/null
+++ b/external-admin/src/pages/BannersPage.jsx
@@ -0,0 +1,49 @@
+import AddOutlined from "@mui/icons-material/AddOutlined";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { useCallback, useMemo, useState } from "react";
+import { useToast } from "@/shared/ui/ToastProvider.jsx";
+import { createBanner, listBanners } from "../api/business.js";
+import { BannerCreateDialog } from "../components/BannerCreateDialog.jsx";
+import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx";
+import { usePagedResource } from "../shared/usePagedResource.js";
+
+export default function BannersPage() {
+ const { showToast } = useToast();
+ const [open, setOpen] = useState(false);
+ const [page, setPage] = useState(1);
+ const [submitting, setSubmitting] = useState(false);
+ const load = useCallback(() => listBanners({ page, page_size: 20 }), [page]);
+ const { data, error, loading, reload } = usePagedResource(load);
+ const columns = useMemo(() => [
+ { key: "banner", label: "Banner", render: (item) => {item.coverUrl ? : null}{item.description || "-"}ID {item.id || "-"} },
+ { key: "type", label: "类型", render: (item) => item.bannerType || "-" },
+ { key: "platform", label: "平台", render: (item) => item.platform || "all" },
+ { key: "sort", label: "排序", render: (item) => item.sortOrder },
+ { key: "status", label: "状态", render: (item) => }
+ ], []);
+
+ const submit = async (form) => {
+ setSubmitting(true);
+ try {
+ await createBanner(form);
+ showToast("Banner 已创建", "success");
+ setOpen(false);
+ reload();
+ } catch (requestError) {
+ showToast(requestError.message || "Banner 创建失败", "error");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+ setOpen(true)} startIcon={} variant="contained">创建 Banner} title="Banner 管理">
+
+ {!loading && !error ? <> item.id} />> : null}
+ setOpen(false)} onSubmit={submit} />
+
+ );
+}
diff --git a/external-admin/src/pages/BansPage.jsx b/external-admin/src/pages/BansPage.jsx
new file mode 100644
index 0000000..773edfb
--- /dev/null
+++ b/external-admin/src/pages/BansPage.jsx
@@ -0,0 +1,61 @@
+import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
+import Avatar from "@mui/material/Avatar";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import Stack from "@mui/material/Stack";
+import TextField from "@mui/material/TextField";
+import Typography from "@mui/material/Typography";
+import { useCallback, useMemo, useState } from "react";
+import { useToast } from "@/shared/ui/ToastProvider.jsx";
+import { listBannedUsers, unbanUser } from "../api/business.js";
+import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
+import { hasExternalCapability } from "../config/capabilities.js";
+import { ConfirmDialog } from "../shared/ConfirmDialog.jsx";
+import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx";
+import { usePagedResource } from "../shared/usePagedResource.js";
+
+export default function BansPage() {
+ const { session } = useExternalAuth();
+ const { showToast } = useToast();
+ const [keyword, setKeyword] = useState("");
+ const [query, setQuery] = useState({ keyword: "", page: 1 });
+ const [target, setTarget] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const load = useCallback(() => listBannedUsers({ keyword: query.keyword, page: query.page, page_size: 20 }), [query]);
+ const { data, error, loading, reload } = usePagedResource(load);
+ const canUnban = hasExternalCapability(session, "user:unban");
+
+ const handleUnban = async () => {
+ setSubmitting(true);
+ try {
+ await unbanUser(target.id, { reason: "external-admin manual unban" });
+ showToast("用户已解除封禁", "success");
+ setTarget(null);
+ reload();
+ } catch (requestError) {
+ showToast(requestError.message || "解除封禁失败", "error");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const columns = useMemo(() => [
+ { key: "user", label: "用户", render: (user) => {user.username.slice(0, 1)}{user.username || "-"}ID {user.id} },
+ { key: "prettyId", label: "短 ID(靓号)", render: (user) => user.prettyId || "-" },
+ { key: "reason", label: "封禁原因", render: (user) => user.reason || "-" },
+ { key: "status", label: "状态", render: () => },
+ { key: "actions", label: "操作", render: (user) => canUnban ? : "-" }
+ ], [canUnban]);
+
+ return (
+
+ { event.preventDefault(); setQuery({ keyword, page: 1 }); }}>
+ setKeyword(event.target.value)} />
+
+
+
+ {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} />> : null}
+ setTarget(null)} onConfirm={handleUnban} />
+
+ );
+}
diff --git a/external-admin/src/pages/ChangePasswordPage.jsx b/external-admin/src/pages/ChangePasswordPage.jsx
new file mode 100644
index 0000000..36d1eb9
--- /dev/null
+++ b/external-admin/src/pages/ChangePasswordPage.jsx
@@ -0,0 +1,69 @@
+import KeyOutlined from "@mui/icons-material/KeyOutlined";
+import Alert from "@mui/material/Alert";
+import Avatar from "@mui/material/Avatar";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import Card from "@mui/material/Card";
+import CardContent from "@mui/material/CardContent";
+import Stack from "@mui/material/Stack";
+import TextField from "@mui/material/TextField";
+import Typography from "@mui/material/Typography";
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
+
+export default function ChangePasswordPage() {
+ const { changePassword, logout, session } = useExternalAuth();
+ const [form, setForm] = useState({ confirmPassword: "", newPassword: "", oldPassword: "" });
+ const [error, setError] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const navigate = useNavigate();
+
+ const submit = async (event) => {
+ event.preventDefault();
+ if (!form.newPassword.trim()) {
+ setError("新密码不能为空或全为空白字符");
+ return;
+ }
+ if (form.newPassword.length < 8) {
+ setError("新密码至少 8 个字符");
+ return;
+ }
+ if (form.newPassword !== form.confirmPassword) {
+ setError("两次输入的新密码不一致");
+ return;
+ }
+ setSubmitting(true);
+ setError("");
+ try {
+ await changePassword({ newPassword: form.newPassword, oldPassword: form.oldPassword });
+ navigate("/overview", { replace: true });
+ } catch (requestError) {
+ setError(requestError.message || "密码修改失败");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const cancel = async () => {
+ await logout();
+ navigate("/login", { replace: true });
+ };
+
+ return (
+
+
+
+ 修改外管密码{session?.passwordChangeRequired ? "首次登录必须修改密码" : session?.account}
+
+ {error ? {error} : null}
+ setForm({ ...form, oldPassword: event.target.value })} />
+ setForm({ ...form, newPassword: event.target.value })} />
+ setForm({ ...form, confirmPassword: event.target.value })} />
+
+
+
+
+
+ );
+}
diff --git a/external-admin/src/pages/ChangePasswordPage.test.jsx b/external-admin/src/pages/ChangePasswordPage.test.jsx
new file mode 100644
index 0000000..a724534
--- /dev/null
+++ b/external-admin/src/pages/ChangePasswordPage.test.jsx
@@ -0,0 +1,47 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter } from "react-router-dom";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import ChangePasswordPage from "./ChangePasswordPage.jsx";
+
+const auth = vi.hoisted(() => ({
+ changePassword: vi.fn(),
+ logout: vi.fn(),
+ session: { account: "fami-manager", passwordChangeRequired: true }
+}));
+
+vi.mock("../auth/ExternalAuthProvider.jsx", () => ({ useExternalAuth: () => auth }));
+
+describe("ChangePasswordPage", () => {
+ beforeEach(() => {
+ auth.changePassword.mockReset().mockResolvedValue({});
+ auth.logout.mockReset().mockResolvedValue(undefined);
+ });
+
+ test("blocks a whitespace-only new password", async () => {
+ const user = userEvent.setup();
+ const { container } = render();
+ const [newPassword, confirmPassword] = container.querySelectorAll('input[autocomplete="new-password"]');
+
+ fireEvent.change(container.querySelector('input[autocomplete="current-password"]'), { target: { value: "old-password" } });
+ fireEvent.change(newPassword, { target: { value: " " } });
+ fireEvent.change(confirmPassword, { target: { value: " " } });
+ await user.click(screen.getByRole("button", { name: "保存密码" }));
+
+ expect(screen.getByText("新密码不能为空或全为空白字符")).toBeInTheDocument();
+ expect(auth.changePassword).not.toHaveBeenCalled();
+ });
+
+ test("preserves password bytes instead of trimming before submission", async () => {
+ const user = userEvent.setup();
+ const { container } = render();
+ const [newPassword, confirmPassword] = container.querySelectorAll('input[autocomplete="new-password"]');
+
+ fireEvent.change(container.querySelector('input[autocomplete="current-password"]'), { target: { value: " current-password " } });
+ fireEvent.change(newPassword, { target: { value: " pass word " } });
+ fireEvent.change(confirmPassword, { target: { value: " pass word " } });
+ await user.click(screen.getByRole("button", { name: "保存密码" }));
+
+ expect(auth.changePassword).toHaveBeenCalledWith({ newPassword: " pass word ", oldPassword: " current-password " });
+ });
+});
diff --git a/external-admin/src/pages/GrantsPage.jsx b/external-admin/src/pages/GrantsPage.jsx
new file mode 100644
index 0000000..4fe3522
--- /dev/null
+++ b/external-admin/src/pages/GrantsPage.jsx
@@ -0,0 +1,106 @@
+import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
+import NumbersOutlined from "@mui/icons-material/NumbersOutlined";
+import Avatar from "@mui/material/Avatar";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { useCallback, useMemo, useState } from "react";
+import { useToast } from "@/shared/ui/ToastProvider.jsx";
+import { grantPrettyId, grantResource, listResourceGrants, listResources, lookupGrantTarget } from "../api/business.js";
+import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
+import { PrettyIdGrantDialog, ResourceGrantDialog } from "../components/GrantDialogs.jsx";
+import { hasAnyExternalCapability, hasExternalCapability } from "../config/capabilities.js";
+import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx";
+import { usePagedResource } from "../shared/usePagedResource.js";
+
+export default function GrantsPage() {
+ const { session } = useExternalAuth();
+ const { showToast } = useToast();
+ const [dialog, setDialog] = useState("");
+ const [page, setPage] = useState(1);
+ const [submitting, setSubmitting] = useState(false);
+ const canList = hasExternalCapability(session, "privilege:list");
+ const canGrant = hasAnyExternalCapability(session, ["privilege:grant", "user-title:grant", "room-background:grant"]);
+ const canGrantPrettyId = hasExternalCapability(session, "pretty-id:grant");
+ const loadResources = useCallback(() => canGrant ? listResources({ page: 1, page_size: 200, status: "active" }) : Promise.resolve({ items: [], page: 1, pageSize: 200, total: 0 }), [canGrant]);
+ const loadGrants = useCallback(() => canList ? listResourceGrants({ page, page_size: 20 }) : Promise.resolve({ items: [], page: 1, pageSize: 20, total: 0 }), [canList, page]);
+ const resourcesState = usePagedResource(loadResources);
+ const grantsState = usePagedResource(loadGrants);
+
+ const execute = async (request, successMessage) => {
+ setSubmitting(true);
+ try {
+ await request();
+ showToast(successMessage, "success");
+ setDialog("");
+ grantsState.reload();
+ } catch (requestError) {
+ showToast(requestError.message || "发放失败", "error");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const resolveTarget = async (input) => {
+ const target = await lookupGrantTarget(input);
+ if (!target.id) {
+ throw new Error("未找到当前 App 内的目标用户");
+ }
+ return target;
+ };
+
+ const submitResource = (form) => execute(async () => {
+ const target = await resolveTarget(form.target);
+ await grantResource({
+ commandId: createCommandId("external-resource-grant"),
+ durationMs: Number(form.durationDays) * 86400000,
+ quantity: Number(form.quantity),
+ reason: form.reason.trim(),
+ resourceId: Number(form.resourceId),
+ targetUserId: target.id
+ });
+ }, "特权道具已发放");
+
+ const submitPrettyId = (form) => execute(async () => {
+ const target = await resolveTarget(form.target);
+ await grantPrettyId({
+ display_user_id: form.prettyId,
+ duration_ms: Number(form.durationDays) * 86400000,
+ reason: form.reason.trim(),
+ target_user_id: target.id
+ });
+ }, "靓号已下发");
+
+ const columns = useMemo(() => [
+ { key: "target", label: "目标用户", render: (item) => String(item.targetUserId || item.target_user_id || item.userId || item.user_id || "-") },
+ { key: "resource", label: "资源", render: (item) => String(item.resourceName || item.resource_name || item.resourceId || item.resource_id || item.subject || "-") },
+ { key: "quantity", label: "数量", render: (item) => String(item.quantity ?? item.amount ?? "-") },
+ { key: "status", label: "状态", render: (item) => },
+ { key: "reason", label: "原因", render: (item) => String(item.reason || "-") }
+ ], []);
+
+ return (
+ {canGrant ? : null}{canGrantPrettyId ? : null}>}
+ title="资源与靓号"
+ >
+ {canGrant ? (
+
+ 可发放资源
+
+ {resourcesState.data.items.slice(0, 12).map((resource) => 资{resource.name})}
+ {!resourcesState.loading && !resourcesState.data.items.length ? 当前无数据 : null}
+
+
+ ) : null}
+ {canList ? <>{!grantsState.loading && !grantsState.error ? <> item.grantId || item.grant_id || index} />> : null}> : null}
+ setDialog("")} onSubmit={submitResource} />
+ setDialog("")} onSubmit={submitPrettyId} />
+
+ );
+}
+
+function createCommandId(prefix) {
+ return `${prefix}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`;
+}
diff --git a/external-admin/src/pages/LoginPage.jsx b/external-admin/src/pages/LoginPage.jsx
new file mode 100644
index 0000000..ba44b37
--- /dev/null
+++ b/external-admin/src/pages/LoginPage.jsx
@@ -0,0 +1,123 @@
+import LockOutlined from "@mui/icons-material/LockOutlined";
+import VisibilityOffOutlined from "@mui/icons-material/VisibilityOffOutlined";
+import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
+import Alert from "@mui/material/Alert";
+import Avatar from "@mui/material/Avatar";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import Card from "@mui/material/Card";
+import CardContent from "@mui/material/CardContent";
+import CircularProgress from "@mui/material/CircularProgress";
+import FormControl from "@mui/material/FormControl";
+import IconButton from "@mui/material/IconButton";
+import InputAdornment from "@mui/material/InputAdornment";
+import InputLabel from "@mui/material/InputLabel";
+import MenuItem from "@mui/material/MenuItem";
+import Select from "@mui/material/Select";
+import Stack from "@mui/material/Stack";
+import TextField from "@mui/material/TextField";
+import Typography from "@mui/material/Typography";
+import { useCallback, useEffect, useState } from "react";
+import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
+import { listExternalApps } from "../api/auth.js";
+import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
+
+export default function LoginPage() {
+ const { initializing, login, session } = useExternalAuth();
+ const [apps, setApps] = useState([]);
+ const [appsError, setAppsError] = useState("");
+ const [appsLoading, setAppsLoading] = useState(true);
+ const [form, setForm] = useState({ account: "", appCode: "", password: "" });
+ const [error, setError] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [showPassword, setShowPassword] = useState(false);
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const loadApps = useCallback(async () => {
+ setAppsLoading(true);
+ setAppsError("");
+ try {
+ const items = await listExternalApps();
+ setApps(items);
+ const requestedApp = searchParams.get("app") || "";
+ const preferredApp = items.find((item) => item.appCode === requestedApp)?.appCode || items[0]?.appCode || requestedApp;
+ setForm((current) => ({ ...current, appCode: current.appCode || preferredApp }));
+ } catch (requestError) {
+ setAppsError(requestError.message || "App 列表加载失败");
+ } finally {
+ setAppsLoading(false);
+ }
+ }, [searchParams]);
+
+ useEffect(() => {
+ loadApps();
+ }, [loadApps]);
+
+ useEffect(() => {
+ if (!initializing && session) {
+ const destination = session.passwordChangeRequired ? "/change-password" : location.state?.from?.pathname || "/overview";
+ navigate(destination, { replace: true });
+ }
+ }, [initializing, location.state, navigate, session]);
+
+ const handleSubmit = async (event) => {
+ event.preventDefault();
+ if (!form.appCode || !form.account.trim() || !form.password) {
+ setError("请选择 App 并填写账号、密码");
+ return;
+ }
+ setSubmitting(true);
+ setError("");
+ try {
+ const nextSession = await login(form);
+ navigate(nextSession.passwordChangeRequired ? "/change-password" : "/overview", { replace: true });
+ } catch (requestError) {
+ setError(requestError.message || "登录失败");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ 外管后台登录
+
+
+ {error ? {error} : null}
+ {appsError ? 重试} severity="error">{appsError} : null}
+ {apps.length || appsLoading ? (
+
+ App
+
+
+ ) : (
+ setForm({ ...form, appCode: event.target.value.trim() })} />
+ )}
+ setForm({ ...form, account: event.target.value })} />
+ setForm({ ...form, password: event.target.value })}
+ slotProps={{ input: { endAdornment: setShowPassword((current) => !current)}>{showPassword ? : } } }}
+ />
+
+
+
+
+
+ );
+}
diff --git a/external-admin/src/pages/OrganizationPage.jsx b/external-admin/src/pages/OrganizationPage.jsx
new file mode 100644
index 0000000..57eb724
--- /dev/null
+++ b/external-admin/src/pages/OrganizationPage.jsx
@@ -0,0 +1,70 @@
+import Avatar from "@mui/material/Avatar";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import Stack from "@mui/material/Stack";
+import TextField from "@mui/material/TextField";
+import Typography from "@mui/material/Typography";
+import Alert from "@mui/material/Alert";
+import { useCallback, useMemo, useState } from "react";
+import { useParams } from "react-router-dom";
+import { listOrganizations } from "../api/business.js";
+import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
+import { hasExternalCapability } from "../config/capabilities.js";
+import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx";
+import { usePagedResource } from "../shared/usePagedResource.js";
+
+const organizationKinds = {
+ agencies: { capability: "agency:list", title: "公会列表" },
+ bds: { capability: "bd:list", title: "BD 列表" },
+ "bd-leaders": { capability: "bd-manager:list", title: "BD Manager 列表" },
+ hosts: { capability: "host:list", title: "主播列表" },
+ managers: { capability: "bd-manager:list", title: "管理员列表" },
+ "super-admins": { capability: "super-admin:list", title: "Super Admin 列表" },
+ team: { capability: "team:view", title: "我的团队" }
+};
+
+export default function OrganizationPage() {
+ const { kind = "hosts" } = useParams();
+ const config = organizationKinds[kind];
+ const { session } = useExternalAuth();
+ const [keyword, setKeyword] = useState("");
+ const [query, setQuery] = useState({ keyword: "", page: 1 });
+ const allowed = Boolean(config && hasExternalCapability(session, config.capability));
+ const load = useCallback(() => allowed ? listOrganizations(kind, { keyword: query.keyword, page: query.page, page_size: 20 }) : Promise.resolve({ items: [], page: 1, pageSize: 20, total: 0 }), [allowed, kind, query]);
+ const { data, error, loading, reload } = usePagedResource(load);
+ const columns = useMemo(() => kind === "team" ? [
+ { key: "agency", label: "公会", render: (item) => 公会 {item.id || "-"}Owner {item.userId || "-"} },
+ { key: "parent", label: "上级 BD", render: (item) => item.parentBdUserId || "-" },
+ { key: "status", label: "状态", render: (item) => }
+ ] : [
+ { key: "identity", label: "成员", render: (item) => {(item.name || item.username || "-").slice(0, 1)}{item.name || item.username || "-"}ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / 用户 ${item.userId}` : ""} },
+ { key: "prettyId", label: "短 ID(靓号)", render: (item) => item.prettyId || "-" },
+ { key: "country", label: "国家/地区", render: (item) => item.country || "-" },
+ { key: "status", label: "状态", render: (item) => }
+ ], [kind]);
+
+ if (!config || !allowed) {
+ return 当前外管账号没有访问该组织列表的权限。;
+ }
+
+ return (
+
+ { event.preventDefault(); setQuery({ keyword, page: 1 }); }}>
+ setKeyword(event.target.value)} />
+
+
+
+ {kind === "team" && !loading && !error ? (
+
+
+ BD Leader:{data.totalBdLeaders || 0}
+ BD:{data.totalBds || 0}
+ 公会:{data.total || data.items.length}
+
+ {data.truncated ? 团队规模超过接口上限,当前结果不完整 : null}
+
+ ) : null}
+ {!loading && !error ? <> item.id || index} /> setQuery({ ...query, page })} />> : null}
+
+ );
+}
diff --git a/external-admin/src/pages/OverviewPage.jsx b/external-admin/src/pages/OverviewPage.jsx
new file mode 100644
index 0000000..c2bfc3a
--- /dev/null
+++ b/external-admin/src/pages/OverviewPage.jsx
@@ -0,0 +1,50 @@
+import CheckCircleOutlineOutlined from "@mui/icons-material/CheckCircleOutlineOutlined";
+import LockOutlined from "@mui/icons-material/LockOutlined";
+import Box from "@mui/material/Box";
+import Card from "@mui/material/Card";
+import CardActionArea from "@mui/material/CardActionArea";
+import CardContent from "@mui/material/CardContent";
+import Chip from "@mui/material/Chip";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { Link } from "react-router-dom";
+import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
+import { EXTERNAL_CAPABILITIES, hasExternalCapability } from "../config/capabilities.js";
+import { ExternalPage } from "../shared/PageComponents.jsx";
+
+export default function OverviewPage() {
+ const { session } = useExternalAuth();
+
+ return (
+
+
+
+
+ 当前 App{session.appName || session.appCode}({session.appCode})
+ 外管账号{session.account}
+ 已开通权限{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length}
+
+
+
+
+ {EXTERNAL_CAPABILITIES.map((item) => {
+ const enabled = hasExternalCapability(session, item.code);
+ const content = (
+
+
+ {enabled ? : }
+ {item.label}
+
+
+
+ );
+ return (
+
+ {enabled ? {content} : content}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/external-admin/src/pages/OverviewPage.test.jsx b/external-admin/src/pages/OverviewPage.test.jsx
new file mode 100644
index 0000000..90540d9
--- /dev/null
+++ b/external-admin/src/pages/OverviewPage.test.jsx
@@ -0,0 +1,31 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { describe, expect, test, vi } from "vitest";
+import { EXTERNAL_CAPABILITIES } from "../config/capabilities.js";
+import OverviewPage from "./OverviewPage.jsx";
+
+const authState = vi.hoisted(() => ({
+ session: {
+ account: "fami-operator",
+ appCode: "fami",
+ appName: "Fami",
+ capabilities: ["user:list"]
+ }
+}));
+
+vi.mock("../auth/ExternalAuthProvider.jsx", () => ({
+ useExternalAuth: () => authState
+}));
+
+describe("OverviewPage", () => {
+ test("renders all 20 market capability entries and only links enabled entries", () => {
+ render();
+
+ expect(EXTERNAL_CAPABILITIES).toHaveLength(20);
+ expect(screen.getAllByText("已开通")).toHaveLength(1);
+ expect(screen.getAllByText("未开通")).toHaveLength(19);
+ expect(screen.getByText("用户列表").closest("a")).toHaveAttribute("href", "/users");
+ expect(screen.getByText("用户信息编辑").closest("a")).toBeNull();
+ expect(screen.getByText("我的团队")).toBeInTheDocument();
+ });
+});
diff --git a/external-admin/src/pages/RoomsPage.jsx b/external-admin/src/pages/RoomsPage.jsx
new file mode 100644
index 0000000..706fecd
--- /dev/null
+++ b/external-admin/src/pages/RoomsPage.jsx
@@ -0,0 +1,63 @@
+import EditOutlined from "@mui/icons-material/EditOutlined";
+import Avatar from "@mui/material/Avatar";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import Stack from "@mui/material/Stack";
+import TextField from "@mui/material/TextField";
+import Typography from "@mui/material/Typography";
+import { useCallback, useMemo, useState } from "react";
+import { useToast } from "@/shared/ui/ToastProvider.jsx";
+import { listRooms, updateRoom } from "../api/business.js";
+import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
+import { RoomEditDialog } from "../components/RoomEditDialog.jsx";
+import { hasExternalCapability } from "../config/capabilities.js";
+import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx";
+import { usePagedResource } from "../shared/usePagedResource.js";
+
+export default function RoomsPage() {
+ const { session } = useExternalAuth();
+ const { showToast } = useToast();
+ const [keyword, setKeyword] = useState("");
+ const [query, setQuery] = useState({ keyword: "", page: 1 });
+ const [room, setRoom] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const load = useCallback(() => listRooms({ keyword: query.keyword, page: query.page, page_size: 20 }), [query]);
+ const { data, error, loading, reload } = usePagedResource(load);
+ const canEdit = hasExternalCapability(session, "room:update");
+
+ const columns = useMemo(() => [
+ { key: "room", label: "房间", render: (item) => 房{item.title || "-"}ID {item.id} },
+ { key: "owner", label: "房主", render: (item) => item.ownerName || "-" },
+ { key: "region", label: "可见区域", render: (item) => item.visibleRegionId || "全部" },
+ { key: "status", label: "状态", render: (item) => },
+ { key: "actions", label: "操作", render: (item) => canEdit ? : "-" }
+ ], [canEdit]);
+
+ const submit = async (form) => {
+ setSubmitting(true);
+ try {
+ const regionText = String(form.visibleRegionId || "").trim();
+ const visibleRegionId = regionText ? Number(regionText) : 0;
+ if (!Number.isSafeInteger(visibleRegionId) || visibleRegionId < 0) {
+ throw new Error("可见区域 ID 必须是安全的正整数");
+ }
+ await updateRoom(room.id, { ...form, visibleRegionId });
+ showToast("房间信息已更新", "success");
+ setRoom(null);
+ reload();
+ } catch (requestError) {
+ showToast(requestError.message || "房间更新失败", "error");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+ { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> setKeyword(event.target.value)} />
+
+ {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} />> : null}
+ setRoom(null)} onSubmit={submit} />
+
+ );
+}
diff --git a/external-admin/src/pages/UsersPage.jsx b/external-admin/src/pages/UsersPage.jsx
new file mode 100644
index 0000000..72db4b7
--- /dev/null
+++ b/external-admin/src/pages/UsersPage.jsx
@@ -0,0 +1,77 @@
+import EditOutlined from "@mui/icons-material/EditOutlined";
+import GppBadOutlined from "@mui/icons-material/GppBadOutlined";
+import TrendingUpOutlined from "@mui/icons-material/TrendingUpOutlined";
+import Avatar from "@mui/material/Avatar";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import MenuItem from "@mui/material/MenuItem";
+import Stack from "@mui/material/Stack";
+import TextField from "@mui/material/TextField";
+import Typography from "@mui/material/Typography";
+import { useCallback, useMemo, useState } from "react";
+import { useToast } from "@/shared/ui/ToastProvider.jsx";
+import { banUser, grantUserVip, listUsers, updateUser, updateUserLevels } from "../api/business.js";
+import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
+import { UserBanDialog, UserEditDialog, UserLevelDialog } from "../components/UserDialogs.jsx";
+import { hasExternalCapability } from "../config/capabilities.js";
+import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx";
+import { usePagedResource } from "../shared/usePagedResource.js";
+
+export default function UsersPage() {
+ const { session } = useExternalAuth();
+ const { showToast } = useToast();
+ const [filters, setFilters] = useState({ keyword: "", status: "" });
+ const [query, setQuery] = useState({ keyword: "", page: 1, status: "" });
+ const [dialog, setDialog] = useState({ type: "", user: null });
+ const [submitting, setSubmitting] = useState(false);
+ const load = useCallback(() => listUsers({ keyword: query.keyword, page: query.page, page_size: 20, status: query.status }), [query]);
+ const { data, error, loading, reload } = usePagedResource(load);
+ const canEdit = hasExternalCapability(session, "user:update");
+ const canBan = hasExternalCapability(session, "user:ban");
+ const canGrantLevel = hasExternalCapability(session, "user-level:grant");
+
+ const closeDialog = () => setDialog({ type: "", user: null });
+ const execute = async (request, successMessage) => {
+ setSubmitting(true);
+ try {
+ await request();
+ showToast(successMessage, "success");
+ closeDialog();
+ reload();
+ } catch (requestError) {
+ showToast(requestError.message || "用户操作失败", "error");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const columns = useMemo(() => [
+ { key: "user", label: "用户", render: (user) => {user.username.slice(0, 1)}{user.username || "-"}ID {user.id} },
+ { key: "prettyId", label: "短 ID(靓号)", render: (user) => user.prettyId || "-" },
+ { key: "country", label: "国家/性别", render: (user) => `${user.country || "-"} / ${user.gender || "-"}` },
+ { key: "level", label: "等级", render: (user) => `财富 ${user.wealthLevel} / 魅力 ${user.charmLevel} / VIP ${user.vipLevel}` },
+ { key: "status", label: "状态", render: (user) => },
+ { key: "actions", label: "操作", render: (user) => {canEdit ? : null}{canGrantLevel ? : null}{canBan && !user.banned ? : null} }
+ ], [canBan, canEdit, canGrantLevel]);
+
+ return (
+
+ { event.preventDefault(); setQuery({ ...filters, page: 1 }); }}>
+ setFilters({ ...filters, keyword: event.target.value })} />
+ setFilters({ ...filters, status: event.target.value })}>
+
+
+
+ {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} />> : null}
+ execute(() => updateUser(dialog.user.id, form), "用户信息已更新")} />
+ execute(() => banUser(dialog.user.id, { expires_at_ms: Number(form.days) === 0 ? 0 : Date.now() + Number(form.days) * 86400000, reason: form.reason.trim() }), "用户已封禁")} />
+ execute(() => form.track === "vip"
+ ? grantUserVip(dialog.user.id, { commandId: createLevelCommandId(), durationMs: Number(form.days) * 86400000, level: Number(form.level), reason: form.reason.trim() })
+ : updateUserLevels(dialog.user.id, { adjustments: [{ duration_days: Number(form.days), level: Number(form.level), track: form.track }], reason: form.reason.trim() }), "用户等级已下发")} />
+
+ );
+}
+
+function createLevelCommandId() {
+ return `external-level-grant-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`;
+}
diff --git a/external-admin/src/shared/ConfirmDialog.jsx b/external-admin/src/shared/ConfirmDialog.jsx
new file mode 100644
index 0000000..e947974
--- /dev/null
+++ b/external-admin/src/shared/ConfirmDialog.jsx
@@ -0,0 +1,16 @@
+import Button from "@mui/material/Button";
+import Dialog from "@mui/material/Dialog";
+import DialogActions from "@mui/material/DialogActions";
+import DialogContent from "@mui/material/DialogContent";
+import DialogContentText from "@mui/material/DialogContentText";
+import DialogTitle from "@mui/material/DialogTitle";
+
+export function ConfirmDialog({ confirmColor = "primary", content, loading, onClose, onConfirm, open, title }) {
+ return (
+
+ );
+}
diff --git a/external-admin/src/shared/ExternalImageUploadField.jsx b/external-admin/src/shared/ExternalImageUploadField.jsx
new file mode 100644
index 0000000..4851533
--- /dev/null
+++ b/external-admin/src/shared/ExternalImageUploadField.jsx
@@ -0,0 +1,64 @@
+import CloudUploadOutlined from "@mui/icons-material/CloudUploadOutlined";
+import Alert from "@mui/material/Alert";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import CircularProgress from "@mui/material/CircularProgress";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { useRef, useState } from "react";
+import { uploadExternalImage } from "../api/business.js";
+
+export function ExternalImageUploadField({ disabled, label, onChange, value }) {
+ const inputRef = useRef(null);
+ const [error, setError] = useState("");
+ const [uploading, setUploading] = useState(false);
+
+ const handleFile = async (event) => {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ if (!file) {
+ return;
+ }
+ if (!file.type.startsWith("image/")) {
+ setError("请选择图片文件");
+ return;
+ }
+ if (file.size > 10 * 1024 * 1024) {
+ setError("图片不能超过 10MB");
+ return;
+ }
+
+ setError("");
+ setUploading(true);
+ try {
+ const url = await uploadExternalImage(file);
+ if (!url) {
+ throw new Error("上传结果缺少图片地址");
+ }
+ onChange(url);
+ } catch (uploadError) {
+ setError(uploadError.message || "图片上传失败");
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ return (
+
+ {label}
+
+ {value ?
: 暂无图片}
+
+
+
+ {error ? {error} : null}
+
+ );
+}
diff --git a/external-admin/src/shared/PageComponents.jsx b/external-admin/src/shared/PageComponents.jsx
new file mode 100644
index 0000000..86da941
--- /dev/null
+++ b/external-admin/src/shared/PageComponents.jsx
@@ -0,0 +1,117 @@
+import Alert from "@mui/material/Alert";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import Card from "@mui/material/Card";
+import CardContent from "@mui/material/CardContent";
+import Chip from "@mui/material/Chip";
+import Skeleton from "@mui/material/Skeleton";
+import Stack from "@mui/material/Stack";
+import Table from "@mui/material/Table";
+import TableBody from "@mui/material/TableBody";
+import TableCell from "@mui/material/TableCell";
+import TableContainer from "@mui/material/TableContainer";
+import TableHead from "@mui/material/TableHead";
+import TableRow from "@mui/material/TableRow";
+import Typography from "@mui/material/Typography";
+import useMediaQuery from "@mui/material/useMediaQuery";
+import { useTheme } from "@mui/material/styles";
+
+export function ExternalPage({ actions, children, title }) {
+ return (
+
+
+
+ {title}
+
+ {actions ? {actions} : null}
+
+ {children}
+
+ );
+}
+
+export function ExternalPageState({ error, loading, onRetry }) {
+ if (loading) {
+ return (
+
+
+
+
+ );
+ }
+ if (error) {
+ return (
+
+ {error}
+
+
+ );
+ }
+ return null;
+}
+
+export function ResponsiveDataList({ columns, emptyText = "当前无数据", items, rowKey }) {
+ const theme = useTheme();
+ const mobile = useMediaQuery(theme.breakpoints.down("sm"));
+
+ if (!items.length) {
+ return {emptyText};
+ }
+
+ if (mobile) {
+ return (
+
+ {items.map((item, index) => (
+
+
+ {columns.map((column) => (
+
+ {column.label}
+ {column.render(item)}
+
+ ))}
+
+
+ ))}
+
+ );
+ }
+
+ return (
+
+
+
+ {columns.map((column) => {column.label})}
+
+
+ {items.map((item, index) => (
+
+ {columns.map((column) => {column.render(item)})}
+
+ ))}
+
+
+
+ );
+}
+
+export function StatusChip({ status }) {
+ const normalized = String(status || "-").toLowerCase();
+ const enabled = ["active", "enabled", "running", "normal", "unbanned"].includes(normalized);
+ const dangerous = ["banned", "disabled", "failed", "blocked"].includes(normalized);
+ return ;
+}
+
+export function PagePagination({ page, pageSize, total, onChange }) {
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+ return (
+
+ 共 {total} 条
+
+
+ {page} / {totalPages}
+
+
+
+ );
+}
diff --git a/external-admin/src/shared/usePagedResource.js b/external-admin/src/shared/usePagedResource.js
new file mode 100644
index 0000000..af442f8
--- /dev/null
+++ b/external-admin/src/shared/usePagedResource.js
@@ -0,0 +1,41 @@
+import { useCallback, useEffect, useState } from "react";
+
+const EMPTY_PAGE = { items: [], page: 1, pageSize: 20, total: 0 };
+
+export function usePagedResource(loader) {
+ const [data, setData] = useState(EMPTY_PAGE);
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [version, setVersion] = useState(0);
+
+ const reload = useCallback(() => setVersion((current) => current + 1), []);
+
+ useEffect(() => {
+ let active = true;
+ setLoading(true);
+ setError("");
+ // Start inside the promise chain so synchronous validation errors use the same visible error state as network failures.
+ Promise.resolve()
+ .then(loader)
+ .then((result) => {
+ if (active) {
+ setData(result || EMPTY_PAGE);
+ }
+ })
+ .catch((requestError) => {
+ if (active) {
+ setError(requestError.message || "数据加载失败");
+ }
+ })
+ .finally(() => {
+ if (active) {
+ setLoading(false);
+ }
+ });
+ return () => {
+ active = false;
+ };
+ }, [loader, version]);
+
+ return { data, error, loading, reload };
+}
diff --git a/external-admin/src/styles/index.css b/external-admin/src/styles/index.css
new file mode 100644
index 0000000..2969394
--- /dev/null
+++ b/external-admin/src/styles/index.css
@@ -0,0 +1,299 @@
+html,
+body,
+#external-admin-root {
+ min-height: 100%;
+ margin: 0;
+}
+
+body {
+ background: var(--bg-page);
+ color: var(--text-primary);
+ font-family: var(--font-system);
+}
+
+.external-login-page {
+ min-height: 100dvh;
+ display: grid;
+ place-items: center;
+ padding: max(24px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
+ background: radial-gradient(circle at 50% 0, rgba(37, 99, 235, 0.13), transparent 42%), var(--bg-page);
+}
+
+.external-login-card {
+ width: min(100%, 420px);
+ border: 1px solid var(--border);
+ box-shadow: var(--shadow-panel);
+}
+
+.external-login-card .MuiCardContent-root {
+ display: grid;
+ gap: 28px;
+ padding: 32px;
+}
+
+.external-login-avatar,
+.external-logo-mark {
+ background: var(--brand-gradient) !important;
+ color: #fff;
+}
+
+.external-full-state {
+ min-height: 100dvh;
+ display: grid;
+ place-content: center;
+ justify-items: center;
+ gap: 16px;
+ padding: 24px;
+}
+
+.external-shell,
+.external-main {
+ min-height: 100dvh;
+}
+
+.external-header {
+ border-bottom: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.96) !important;
+ backdrop-filter: blur(12px);
+}
+
+.external-header .MuiToolbar-root {
+ min-height: 64px;
+ gap: 10px;
+}
+
+.external-user-avatar {
+ width: 34px !important;
+ height: 34px !important;
+ font-size: 13px !important;
+ background: var(--primary) !important;
+}
+
+.external-sidebar {
+ min-height: 100%;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-sidebar);
+}
+
+.external-logo {
+ width: 248px;
+ min-height: 72px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 20px;
+}
+
+.external-logo-mark {
+ width: 38px;
+ height: 38px;
+ display: grid;
+ place-items: center;
+ border-radius: 10px;
+ font-weight: 800;
+ box-shadow: var(--brand-shadow);
+}
+
+.external-nav {
+ padding: 12px !important;
+ overflow-y: auto;
+}
+
+.external-nav .MuiListItemButton-root {
+ min-height: 44px;
+ margin-bottom: 4px;
+ border-radius: 8px;
+ color: var(--text-secondary);
+ transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard), transform var(--motion-base) var(--ease-emphasized);
+}
+
+.external-nav .MuiListItemButton-root:hover {
+ background: var(--bg-hover);
+}
+
+.external-nav .MuiListItemButton-root.Mui-selected,
+.external-nav .MuiListItemButton-root.Mui-selected:hover {
+ background: var(--primary-surface-strong);
+ color: var(--primary);
+ font-weight: 700;
+}
+
+.external-nav .MuiListItemIcon-root {
+ min-width: 38px;
+ color: inherit;
+}
+
+.external-main {
+ background: var(--bg-page);
+}
+
+.external-page {
+ display: grid;
+ gap: 20px;
+ padding: 24px;
+ animation: external-page-enter var(--motion-slow) var(--ease-emphasized) both;
+}
+
+.external-session-card,
+.external-table-container,
+.external-filter-bar,
+.external-form-panel,
+.external-empty {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-card);
+ background: var(--bg-card);
+}
+
+.external-filter-bar,
+.external-form-panel {
+ padding: 16px;
+}
+
+.external-filter-bar .MuiTextField-root {
+ min-width: min(100%, 220px);
+}
+
+.external-capability-grid {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.external-capability-card {
+ min-height: 150px;
+ opacity: 0.68;
+}
+
+.external-capability-card.is-enabled {
+ opacity: 1;
+}
+
+.external-capability-card.is-enabled:hover {
+ border-color: var(--primary-border);
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-soft);
+}
+
+.external-capability-card .MuiCardActionArea-root,
+.external-capability-card .MuiCardContent-root {
+ height: 100%;
+}
+
+.external-table-container {
+ overflow-x: auto;
+}
+
+.external-table-container .MuiTableHead-root {
+ background: var(--bg-card-strong);
+}
+
+.external-table-container .MuiTableCell-root {
+ white-space: nowrap;
+ border-color: var(--row-border);
+}
+
+.external-empty {
+ min-height: 200px;
+ display: grid;
+ place-items: center;
+ color: var(--text-secondary);
+}
+
+.external-skeleton {
+ min-height: 50vh;
+ display: grid;
+ grid-template-rows: auto 1fr;
+ gap: 12px;
+}
+
+.external-page-number {
+ min-width: 64px;
+ display: grid;
+ place-items: center;
+}
+
+.external-mobile-card .external-mobile-row {
+ display: grid;
+ grid-template-columns: minmax(92px, 0.38fr) minmax(0, 1fr);
+ align-items: start;
+ gap: 12px;
+ padding: 10px 0;
+ border-bottom: 1px solid var(--row-border);
+}
+
+.external-mobile-card .external-mobile-row:last-child {
+ border-bottom: 0;
+}
+
+.external-dialog-fields {
+ padding-top: 8px;
+}
+
+.external-image-field {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-height: 92px;
+ padding: 12px;
+ border: 1px dashed var(--border);
+ border-radius: var(--radius-control);
+}
+
+.external-image-field img,
+.external-image-placeholder {
+ width: 104px;
+ height: 68px;
+ object-fit: cover;
+ border-radius: 6px;
+ background: var(--bg-card-strong);
+}
+
+.external-banner-thumb {
+ width: 88px;
+ height: 50px;
+ object-fit: cover;
+ border-radius: 6px;
+ background: var(--bg-card-strong);
+}
+
+.external-image-placeholder {
+ display: grid;
+ place-items: center;
+ color: var(--text-tertiary);
+ font-size: 12px;
+}
+
+@keyframes external-page-enter {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@media (max-width: 1199px) {
+ .external-capability-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+
+@media (max-width: 899px) {
+ .external-page { padding: 18px; }
+ .external-capability-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+
+@media (max-width: 599px) {
+ .external-login-card .MuiCardContent-root { padding: 24px 20px; }
+ .external-page { padding: 14px; gap: 14px; }
+ .external-capability-grid { grid-template-columns: 1fr; }
+ .external-capability-card { min-height: 128px; }
+ .external-filter-bar .MuiTextField-root { width: 100%; }
+ .external-image-field { align-items: flex-start; flex-direction: column; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js
index 66267e9..799281e 100644
--- a/src/app/navigation/menu.js
+++ b/src/app/navigation/menu.js
@@ -191,6 +191,7 @@ export const fallbackNavigation = [
routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }),
routeNavItem("operation-coin-seller-ledger", { icon: ReceiptLongOutlined }),
routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }),
+ routeNavItem("operation-external-admin-users", { icon: ManageAccountsOutlined }),
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
routeNavItem("operation-reports", { icon: FlagOutlined }),
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
diff --git a/src/app/navigation/menu.test.jsx b/src/app/navigation/menu.test.jsx
index 33f9e5e..46520a7 100644
--- a/src/app/navigation/menu.test.jsx
+++ b/src/app/navigation/menu.test.jsx
@@ -100,6 +100,23 @@ describe("navigation menu helpers", () => {
expect(flattenCodes(merged)).toContain("host-org-managers");
});
+ test("places external admin users under operations with an isolated permission", () => {
+ const items = filterNavigationByPermission(
+ fallbackNavigation,
+ (code) => code === "external-admin-user:view",
+ );
+ const operations = items.find((item) => item.code === "operations");
+
+ expect(operations?.children).toEqual([
+ expect.objectContaining({
+ code: "operation-external-admin-users",
+ label: "外管用户",
+ path: "/operations/external-admin-users",
+ permissionCode: "external-admin-user:view",
+ }),
+ ]);
+ });
+
test("keeps payment children under payment menu when backend menu is current", () => {
const mapped = mapBackendMenus([
{ children: [], code: "app-config", icon: "settings", id: "app-config", label: "APP配置" },
diff --git a/src/app/permissions.ts b/src/app/permissions.ts
index a8904fe..ea6a475 100644
--- a/src/app/permissions.ts
+++ b/src/app/permissions.ts
@@ -55,11 +55,16 @@ export const PERMISSIONS = {
financeOrderCoinSellerRechargeCreate: "finance-order:coin-seller-recharge:create",
financeOrderCoinSellerRechargeVerify: "finance-order:coin-seller-recharge:verify",
financeOrderCoinSellerRechargeGrant: "finance-order:coin-seller-recharge:grant",
+ financeOrderUSDTAddressUpdate: "finance-order:usdt-address:update",
coinLedgerView: "coin-ledger:view",
coinSellerLedgerView: "coin-seller-ledger:view",
giftRecordView: "gift-record:view",
coinAdjustmentView: "coin-adjustment:view",
coinAdjustmentCreate: "coin-adjustment:create",
+ externalAdminUserView: "external-admin-user:view",
+ externalAdminUserCreate: "external-admin-user:create",
+ externalAdminUserStatus: "external-admin-user:status",
+ externalAdminUserResetPassword: "external-admin-user:reset-password",
reportView: "report:view",
giftDiamondView: "gift-diamond:view",
giftDiamondUpdate: "gift-diamond:update",
@@ -261,6 +266,7 @@ export const MENU_CODES = {
operationCoinSellerLedger: "operation-coin-seller-ledger",
operationGiftRecords: "operation-gift-records",
operationCoinAdjustment: "operation-coin-adjustment",
+ operationExternalAdminUsers: "operation-external-admin-users",
operationReports: "operation-reports",
operationGiftDiamond: "operation-gift-diamond",
policyTemplate: "policy-template",
diff --git a/src/app/router/routeConfig.ts b/src/app/router/routeConfig.ts
index 42272d8..ce62705 100644
--- a/src/app/router/routeConfig.ts
+++ b/src/app/router/routeConfig.ts
@@ -9,6 +9,7 @@ import { cpConfigRoutes } from "@/features/cp-config/routes.js";
import { cpWeeklyRankRoutes } from "@/features/cp-weekly-rank/routes.js";
import { dashboardRoutes } from "@/features/dashboard/routes.js";
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
+import { externalAdminUserRoutes } from "@/features/external-admin-users/routes.js";
import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js";
import { gameRoutes } from "@/features/games/routes.js";
import { hostAgencyPolicyRoutes } from "@/features/host-agency-policy/routes.js";
@@ -68,6 +69,7 @@ export const adminRoutes: AdminRoute[] = [
...vipConfigRoutes,
...resourceRoutes,
...operationsRoutes,
+ ...externalAdminUserRoutes,
...policyConfigRoutes,
...luckyGiftRoutes,
...paymentRoutes,
diff --git a/src/features/external-admin-users/api.test.ts b/src/features/external-admin-users/api.test.ts
new file mode 100644
index 0000000..bc3e742
--- /dev/null
+++ b/src/features/external-admin-users/api.test.ts
@@ -0,0 +1,121 @@
+import { afterEach, expect, test, vi } from "vitest";
+import { setAccessToken, setSelectedAppCode } from "@/shared/api/request";
+import {
+ createExternalAdminUser,
+ listExternalAdminUsers,
+ lookupExternalAdminUserTarget,
+ resetExternalAdminUserPassword,
+ updateExternalAdminUserStatus,
+} from "./api";
+
+afterEach(() => {
+ setAccessToken("");
+ setSelectedAppCode("lalu");
+ vi.unstubAllGlobals();
+});
+
+test("external admin APIs pin every read and write to the explicit App scope", async () => {
+ setSelectedAppCode("lalu");
+ const externalUser = externalUserFixture();
+ vi.stubGlobal(
+ "fetch",
+ vi.fn()
+ .mockResolvedValueOnce(envelope({ items: [externalUser], page: 1, page_size: 50, total: 1 }))
+ .mockResolvedValueOnce(envelope({ linked_user: externalUser.linked_user }))
+ .mockResolvedValueOnce(envelope(externalUser, 201))
+ .mockResolvedValueOnce(envelope({ ...externalUser, status: "disabled" }))
+ .mockResolvedValueOnce(envelope(externalUser)),
+ );
+
+ const list = await listExternalAdminUsers(" Huwaa ", {
+ keyword: "ops.fami",
+ page: 1,
+ page_size: 50,
+ status: "active",
+ });
+ const target = await lookupExternalAdminUserTarget("huwaa", "VIP-1001");
+ await createExternalAdminUser("huwaa", {
+ password: "StrongPass123!",
+ targetUserId: "user-1001",
+ username: "ops.fami",
+ });
+ await updateExternalAdminUserStatus("huwaa", 71, "disabled");
+ await resetExternalAdminUserPassword("huwaa", 71, "NextStrongPass123!");
+
+ const calls = vi.mocked(fetch).mock.calls;
+ expect(calls.map(([, init]) => init?.method)).toEqual(["GET", "GET", "POST", "PATCH", "POST"]);
+ calls.forEach(([, init]) => {
+ expect(init?.headers).toMatchObject({ "X-App-Code": "huwaa" });
+ });
+ expect(String(calls[0][0])).toContain("/api/v1/admin/external-admin-users?");
+ expect(String(calls[0][0])).toContain("keyword=ops.fami");
+ expect(String(calls[0][0])).toContain("status=active");
+ expect(String(calls[1][0])).toContain("/api/v1/admin/external-admin-users/target?display_user_id=VIP-1001");
+ expect(String(calls[3][0])).toContain("/api/v1/admin/external-admin-users/71/status");
+ expect(String(calls[4][0])).toContain("/api/v1/admin/external-admin-users/71/password");
+ expect(requestBody(calls[2][1])).toEqual({
+ password: "StrongPass123!",
+ targetUserId: "user-1001",
+ username: "ops.fami",
+ });
+ expect(requestBody(calls[3][1])).toEqual({ status: "disabled" });
+ expect(requestBody(calls[4][1])).toEqual({ password: "NextStrongPass123!" });
+ expect(list).toMatchObject({
+ page: 1,
+ pageSize: 50,
+ total: 1,
+ items: [
+ {
+ appCode: "huwaa",
+ createdAtMs: 1784073600000,
+ createdByAdminId: 9,
+ id: 71,
+ linkedUser: {
+ defaultDisplayUserId: "1001",
+ prettyDisplayUserId: "VIP-1001",
+ userId: "user-1001",
+ },
+ permissions: ["user:list", "room:edit"],
+ status: "active",
+ username: "ops.fami",
+ },
+ ],
+ });
+ expect(target).toMatchObject({
+ defaultDisplayUserId: "1001",
+ prettyDisplayUserId: "VIP-1001",
+ userId: "user-1001",
+ });
+});
+
+function envelope(data: unknown, status = 200) {
+ return new Response(JSON.stringify({ code: 0, data }), { status });
+}
+
+function requestBody(init: RequestInit | undefined) {
+ return JSON.parse(String(init?.body || "{}"));
+}
+
+function externalUserFixture() {
+ return {
+ app_code: "huwaa",
+ created_at_ms: "1784073600000",
+ created_by_admin_id: 9,
+ id: 71,
+ last_login_at_ms: null,
+ linked_user: {
+ avatar: "https://cdn.example.com/u.png",
+ default_display_user_id: "1001",
+ display_user_id: "VIP-1001",
+ pretty_display_user_id: "VIP-1001",
+ pretty_id: "pretty-1",
+ status: "active",
+ user_id: "user-1001",
+ username: "Fami user",
+ },
+ permissions: ["user:list", "room:edit"],
+ status: "active",
+ updated_at_ms: "1784077200000",
+ username: "ops.fami",
+ };
+}
diff --git a/src/features/external-admin-users/api.ts b/src/features/external-admin-users/api.ts
new file mode 100644
index 0000000..e815302
--- /dev/null
+++ b/src/features/external-admin-users/api.ts
@@ -0,0 +1,223 @@
+import { apiRequest } from "@/shared/api/request";
+import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
+import type { ApiPage, CoinLedgerUserDto, EntityId, PageQuery } from "@/shared/api/types";
+
+export type ExternalAdminUserStatus = "active" | "disabled";
+
+export interface ExternalAdminLinkedUserDto extends CoinLedgerUserDto {
+ defaultDisplayUserId?: string;
+ status?: string;
+}
+
+export interface ExternalAdminUserDto {
+ appCode?: string;
+ createdAtMs?: number;
+ createdByAdminId?: EntityId;
+ id: EntityId;
+ lastLoginAtMs?: number;
+ linkedUser: ExternalAdminLinkedUserDto;
+ permissions: string[];
+ status: ExternalAdminUserStatus;
+ updatedAtMs?: number;
+ username: string;
+}
+
+export interface ExternalAdminUserTargetDto extends ExternalAdminLinkedUserDto {
+ existingExternalAdminUser?: ExternalAdminUserDto;
+}
+
+export interface CreateExternalAdminUserPayload {
+ password: string;
+ targetUserId: string;
+ username: string;
+}
+
+export async function listExternalAdminUsers(
+ appCode: string,
+ query: PageQuery = {},
+): Promise> {
+ const endpoint = API_ENDPOINTS.listExternalAdminUsers;
+ const data = await apiRequest>(apiEndpointPath(API_OPERATIONS.listExternalAdminUsers), {
+ headers: appScopeHeaders(appCode),
+ method: endpoint.method,
+ query,
+ });
+ return normalizePage(data, query);
+}
+
+export async function lookupExternalAdminUserTarget(
+ appCode: string,
+ displayUserId: string,
+): Promise {
+ const endpoint = API_ENDPOINTS.lookupExternalAdminUserTarget;
+ const data = await apiRequest(apiEndpointPath(API_OPERATIONS.lookupExternalAdminUserTarget), {
+ headers: appScopeHeaders(appCode),
+ method: endpoint.method,
+ query: { display_user_id: displayUserId },
+ });
+ return normalizeTarget(data);
+}
+
+export async function createExternalAdminUser(
+ appCode: string,
+ payload: CreateExternalAdminUserPayload,
+): Promise {
+ const endpoint = API_ENDPOINTS.createExternalAdminUser;
+ const data = await apiRequest(
+ apiEndpointPath(API_OPERATIONS.createExternalAdminUser),
+ {
+ body: payload,
+ headers: appScopeHeaders(appCode),
+ method: endpoint.method,
+ },
+ );
+ return normalizeExternalAdminUser(data);
+}
+
+export async function updateExternalAdminUserStatus(
+ appCode: string,
+ id: EntityId,
+ status: ExternalAdminUserStatus,
+): Promise {
+ const endpoint = API_ENDPOINTS.updateExternalAdminUserStatus;
+ const data = await apiRequest(
+ apiEndpointPath(API_OPERATIONS.updateExternalAdminUserStatus, { id }),
+ {
+ body: { status },
+ headers: appScopeHeaders(appCode),
+ method: endpoint.method,
+ },
+ );
+ return normalizeExternalAdminUser(data);
+}
+
+export async function resetExternalAdminUserPassword(
+ appCode: string,
+ id: EntityId,
+ password: string,
+): Promise {
+ const endpoint = API_ENDPOINTS.resetExternalAdminUserPassword;
+ const data = await apiRequest(
+ apiEndpointPath(API_OPERATIONS.resetExternalAdminUserPassword, { id }),
+ {
+ body: { password },
+ headers: appScopeHeaders(appCode),
+ method: endpoint.method,
+ },
+ );
+ return normalizeExternalAdminUser(data);
+}
+
+function appScopeHeaders(appCode: string) {
+ // 外管账号是强 App 隔离数据;显式固定请求头,避免切换 App 后全局请求上下文把写操作送到另一租户。
+ return { "X-App-Code": String(appCode || "").trim().toLowerCase() };
+}
+
+type RawRecord = Record;
+
+interface RawPage {
+ items?: TItem[];
+ page?: number;
+ page_size?: number;
+ pageSize?: number;
+ total?: number;
+}
+
+function normalizePage(data: RawPage, query: PageQuery): ApiPage {
+ return {
+ items: (Array.isArray(data?.items) ? data.items : []).map(normalizeExternalAdminUser),
+ page: numberValue(data?.page, Number(query.page || 1)),
+ pageSize: numberValue(data?.pageSize ?? data?.page_size, Number(query.page_size || 50)),
+ total: numberValue(data?.total),
+ };
+}
+
+function normalizeExternalAdminUser(raw: RawRecord): ExternalAdminUserDto {
+ const item = asRecord(raw) || {};
+ const linkedUser = normalizeUser(
+ asRecord(item.linkedUser) ||
+ asRecord(item.linked_user) ||
+ asRecord(item.user) ||
+ asRecord(item.targetUser) ||
+ asRecord(item.target_user) ||
+ item,
+ );
+ const status = stringValue(item.status).toLowerCase();
+ return {
+ appCode: stringValue(item.appCode ?? item.app_code),
+ createdAtMs: optionalNumber(item.createdAtMs ?? item.created_at_ms),
+ createdByAdminId: entityValue(item.createdByAdminId ?? item.created_by_admin_id),
+ id: entityValue(item.id ?? item.externalAdminUserId ?? item.external_admin_user_id),
+ lastLoginAtMs: optionalNumber(item.lastLoginAtMs ?? item.last_login_at_ms),
+ linkedUser,
+ permissions: stringArray(item.permissions),
+ status: status === "disabled" ? "disabled" : "active",
+ updatedAtMs: optionalNumber(item.updatedAtMs ?? item.updated_at_ms),
+ username: stringValue(item.username ?? item.account ?? item.accountName ?? item.account_name),
+ };
+}
+
+function normalizeTarget(raw: RawRecord): ExternalAdminUserTargetDto {
+ const item = asRecord(raw) || {};
+ const user = normalizeUser(
+ asRecord(item.linkedUser) ||
+ asRecord(item.linked_user) ||
+ asRecord(item.user) ||
+ asRecord(item.targetUser) ||
+ asRecord(item.target_user) ||
+ item,
+ );
+ const existing =
+ asRecord(item.existingExternalAdminUser) ||
+ asRecord(item.existing_external_admin_user) ||
+ asRecord(item.existingAccount) ||
+ asRecord(item.existing_account);
+ return {
+ ...user,
+ existingExternalAdminUser: existing ? normalizeExternalAdminUser(existing) : undefined,
+ };
+}
+
+function normalizeUser(raw: RawRecord): ExternalAdminLinkedUserDto {
+ const user = asRecord(raw) || {};
+ return {
+ avatar: stringValue(user.avatar),
+ defaultDisplayUserId: stringValue(user.defaultDisplayUserId ?? user.default_display_user_id),
+ displayUserId: stringValue(
+ user.displayUserId ?? user.display_user_id ?? user.defaultDisplayUserId ?? user.default_display_user_id,
+ ),
+ prettyDisplayUserId: stringValue(user.prettyDisplayUserId ?? user.pretty_display_user_id),
+ prettyId: stringValue(user.prettyId ?? user.pretty_id),
+ status: stringValue(user.status),
+ userId: stringValue(user.userId ?? user.user_id ?? user.targetUserId ?? user.target_user_id),
+ username: stringValue(user.username ?? user.name),
+ };
+}
+
+function asRecord(value: unknown): RawRecord | undefined {
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as RawRecord) : undefined;
+}
+
+function entityValue(value: unknown): EntityId {
+ return typeof value === "number" ? value : stringValue(value);
+}
+
+function numberValue(value: unknown, fallback = 0) {
+ const number = Number(value);
+ return Number.isFinite(number) ? number : fallback;
+}
+
+function optionalNumber(value: unknown) {
+ if (value === undefined || value === null || value === "") {
+ return undefined;
+ }
+ return numberValue(value);
+}
+
+function stringValue(value: unknown) {
+ return value === undefined || value === null ? "" : String(value);
+}
+
+function stringArray(value: unknown) {
+ return Array.isArray(value) ? value.map(stringValue).filter(Boolean) : [];
+}
diff --git a/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx b/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx
new file mode 100644
index 0000000..ce0a679
--- /dev/null
+++ b/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx
@@ -0,0 +1,113 @@
+import SearchOutlined from "@mui/icons-material/SearchOutlined";
+import TextField from "@mui/material/TextField";
+import { Button } from "@/shared/ui/Button.jsx";
+import {
+ AdminFormDialog,
+ AdminFormFieldGrid,
+ AdminFormSection,
+} from "@/shared/ui/AdminFormDialog.jsx";
+import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
+import styles from "@/features/external-admin-users/external-admin-users.module.css";
+
+export function ExternalAdminUserFormDialog({
+ abilities,
+ form,
+ loading,
+ lookupLoading,
+ onClose,
+ onDisplayUserIdChange,
+ onFormChange,
+ onLookup,
+ onSubmit,
+ open,
+ targetUser,
+}) {
+ const existingAccount = targetUser?.existingExternalAdminUser;
+
+ return (
+
+
+
+ onDisplayUserIdChange(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ onLookup();
+ }
+ }}
+ />
+ }
+ onClick={onLookup}
+ >
+ {lookupLoading ? "搜索中" : "搜索"}
+
+
+ {targetUser ? (
+
+
+ {existingAccount ? (
+
+ 该用户已绑定外管账号 {existingAccount.username || existingAccount.id},不能重复创建。
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+ onFormChange({ username: event.target.value })}
+ />
+ onFormChange({ password: event.target.value })}
+ />
+ onFormChange({ confirmPassword: event.target.value })}
+ />
+
+
+
+ );
+}
diff --git a/src/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx b/src/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx
new file mode 100644
index 0000000..aac97ea
--- /dev/null
+++ b/src/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx
@@ -0,0 +1,55 @@
+import TextField from "@mui/material/TextField";
+import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
+import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
+
+export function ExternalAdminUserPasswordDialog({
+ abilities,
+ form,
+ loading,
+ onClose,
+ onFormChange,
+ onSubmit,
+ user,
+}) {
+ return (
+
+
+
+
+
+
+ onFormChange({ password: event.target.value })}
+ />
+ onFormChange({ confirmPassword: event.target.value })}
+ />
+
+
+
+ );
+}
diff --git a/src/features/external-admin-users/external-admin-users.module.css b/src/features/external-admin-users/external-admin-users.module.css
new file mode 100644
index 0000000..35ff7ed
--- /dev/null
+++ b/src/features/external-admin-users/external-admin-users.module.css
@@ -0,0 +1,61 @@
+.lookupRow {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: start;
+ gap: var(--space-3);
+}
+
+.lookupButton {
+ min-width: 92px;
+}
+
+.targetPanel {
+ display: grid;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-control);
+ background: var(--bg-card-strong);
+}
+
+.existingWarning {
+ padding: var(--space-3);
+ border: 1px solid var(--danger-border);
+ border-radius: var(--radius-control);
+ background: var(--danger-surface);
+ color: var(--danger);
+ font-size: 13px;
+ line-height: 20px;
+}
+
+.accountCell {
+ display: grid;
+ min-width: 0;
+ gap: var(--space-1);
+}
+
+.accountName {
+ overflow: hidden;
+ color: var(--text-primary);
+ font-weight: 720;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.accountMeta {
+ overflow: hidden;
+ color: var(--text-tertiary);
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+@media (max-width: 760px) {
+ .lookupRow {
+ grid-template-columns: 1fr;
+ }
+
+ .lookupButton {
+ width: 100%;
+ }
+}
diff --git a/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js
new file mode 100644
index 0000000..4584d50
--- /dev/null
+++ b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js
@@ -0,0 +1,377 @@
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
+import {
+ createExternalAdminUser,
+ listExternalAdminUsers,
+ lookupExternalAdminUserTarget,
+ resetExternalAdminUserPassword,
+ updateExternalAdminUserStatus,
+} from "@/features/external-admin-users/api";
+import { useExternalAdminUserAbilities } from "@/features/external-admin-users/permissions.js";
+import {
+ externalAdminUserCreateFormSchema,
+ externalAdminUserLookupSchema,
+ externalAdminUserPasswordFormSchema,
+} from "@/features/external-admin-users/schema";
+import { toPageQuery } from "@/shared/api/query";
+import { parseForm } from "@/shared/forms/validation";
+import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
+import { mergePaginatedItems } from "@/shared/hooks/usePaginatedQuery.js";
+import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
+import { useToast } from "@/shared/ui/ToastProvider.jsx";
+
+const pageSize = 50;
+const emptyPage = { items: [], page: 1, pageSize, total: 0 };
+const emptyCreateForm = () => ({ confirmPassword: "", displayUserId: "", password: "", username: "" });
+const emptyPasswordForm = () => ({ confirmPassword: "", password: "" });
+
+export function useExternalAdminUsersPage() {
+ const { appCode } = useAppScope();
+ const abilities = useExternalAdminUserAbilities();
+ const confirm = useConfirm();
+ const queryClient = useQueryClient();
+ const { showToast } = useToast();
+ const currentAppCodeRef = useRef(appCode);
+ const lookupRequestRef = useRef(0);
+
+ const [keyword, setKeywordState] = useState("");
+ const [status, setStatusState] = useState("");
+ const [page, setPage] = useState(1);
+ const [mergedPage, setMergedPage] = useState(() => ({ ...emptyPage, appCode }));
+ const [createOpen, setCreateOpen] = useState(false);
+ const [createForm, setCreateFormState] = useState(emptyCreateForm);
+ const [targetUser, setTargetUser] = useState(null);
+ const [lookupLoading, setLookupLoading] = useState(false);
+ const [passwordUser, setPasswordUser] = useState(null);
+ const [passwordForm, setPasswordFormState] = useState(emptyPasswordForm);
+ const [actionAppCode, setActionAppCode] = useState("");
+ const [loadingAction, setLoadingAction] = useState("");
+
+ const requestQuery = useMemo(
+ () => toPageQuery({ keyword, page, pageSize, status }),
+ [keyword, page, status],
+ );
+ const queryFn = useCallback(() => listExternalAdminUsers(appCode, requestQuery), [appCode, requestQuery]);
+ const {
+ data: queryPage = emptyPage,
+ error,
+ loading: queryLoading,
+ reload,
+ } = useAdminQuery(queryFn, {
+ enabled: Boolean(appCode && abilities.canView),
+ errorMessage: "加载外管用户失败",
+ initialData: emptyPage,
+ queryKey: ["external-admin-users", appCode, requestQuery],
+ });
+
+ useLayoutEffect(() => {
+ // 写操作完成回调依赖最新租户;布局 effect 在用户能继续交互前同步边界,又不在渲染阶段读写 ref。
+ currentAppCodeRef.current = appCode;
+ }, [appCode]);
+
+ useEffect(() => {
+ // App 是外管账号的租户边界;切换后必须立即丢弃账号、密码和用户匹配结果,禁止跨 App 复用敏感表单。
+ lookupRequestRef.current += 1;
+ setCreateOpen(false);
+ setCreateFormState(emptyCreateForm());
+ setTargetUser(null);
+ setLookupLoading(false);
+ setPasswordUser(null);
+ setPasswordFormState(emptyPasswordForm());
+ setActionAppCode("");
+ setLoadingAction("");
+ setKeywordState("");
+ setStatusState("");
+ setPage(1);
+ setMergedPage({ ...emptyPage, appCode });
+ }, [appCode]);
+
+ useEffect(() => {
+ setMergedPage({ ...emptyPage, appCode });
+ }, [appCode, keyword, status]);
+
+ useEffect(() => {
+ if (queryLoading) {
+ return;
+ }
+ const nextItems = Array.isArray(queryPage?.items) ? queryPage.items : [];
+ const nextPage = Number(queryPage?.page || page || 1);
+ const nextPageSize = Number(queryPage?.pageSize || pageSize);
+ const nextTotal = Number(queryPage?.total || 0);
+
+ setMergedPage((current) => {
+ // 即使旧 App 请求晚到,也不能进入当前表格;scope 标记同时避免 React effect 执行前闪现旧租户数据。
+ if (current.appCode !== appCode || nextPage <= 1) {
+ return { ...queryPage, appCode, items: nextItems, page: nextPage, pageSize: nextPageSize, total: nextTotal };
+ }
+ return {
+ ...queryPage,
+ appCode,
+ items: mergePaginatedItems(current.items || [], nextItems),
+ page: nextPage,
+ pageSize: nextPageSize,
+ total: nextTotal,
+ };
+ });
+ }, [appCode, page, queryLoading, queryPage]);
+
+ const data = mergedPage.appCode === appCode ? mergedPage : { ...emptyPage, appCode };
+ const loading = queryLoading && data.items.length === 0;
+
+ const setKeyword = (value) => {
+ setKeywordState(value);
+ setPage(1);
+ };
+ const setStatus = (value) => {
+ setStatusState(value);
+ setPage(1);
+ };
+ const resetFilters = () => {
+ setKeywordState("");
+ setStatusState("");
+ setPage(1);
+ };
+
+ const setCreateForm = (patch) => {
+ setCreateFormState((current) => ({ ...current, ...patch }));
+ };
+ const changeDisplayUserId = (value) => {
+ lookupRequestRef.current += 1;
+ setCreateForm({ displayUserId: value });
+ setTargetUser(null);
+ setLookupLoading(false);
+ };
+ const openCreate = () => {
+ lookupRequestRef.current += 1;
+ setCreateFormState(emptyCreateForm());
+ setTargetUser(null);
+ setLookupLoading(false);
+ setActionAppCode(appCode);
+ setCreateOpen(true);
+ };
+ const closeCreate = () => {
+ if (loadingAction === "create") {
+ return;
+ }
+ lookupRequestRef.current += 1;
+ setCreateOpen(false);
+ setCreateFormState(emptyCreateForm());
+ setTargetUser(null);
+ setLookupLoading(false);
+ setActionAppCode("");
+ };
+
+ const lookupTarget = async () => {
+ let displayUserId;
+ try {
+ ({ displayUserId } = parseForm(externalAdminUserLookupSchema, createForm));
+ } catch (err) {
+ showToast(err.message || "请输入用户短 ID / 靓号", "error");
+ return;
+ }
+ const scope = appCode;
+ const requestId = lookupRequestRef.current + 1;
+ lookupRequestRef.current = requestId;
+ setLookupLoading(true);
+ setTargetUser(null);
+ try {
+ const result = await lookupExternalAdminUserTarget(scope, displayUserId);
+ if (currentAppCodeRef.current === scope && lookupRequestRef.current === requestId) {
+ setTargetUser(result);
+ }
+ } catch (err) {
+ if (currentAppCodeRef.current === scope && lookupRequestRef.current === requestId) {
+ showToast(err.message || "查询 App 用户失败", "error");
+ }
+ } finally {
+ if (currentAppCodeRef.current === scope && lookupRequestRef.current === requestId) {
+ setLookupLoading(false);
+ }
+ }
+ };
+
+ const refreshList = useCallback(
+ async (scope) => {
+ if (currentAppCodeRef.current !== scope) {
+ return;
+ }
+ setPage(1);
+ setMergedPage({ ...emptyPage, appCode: scope });
+ await queryClient.invalidateQueries({ queryKey: ["external-admin-users", scope] });
+ },
+ [queryClient],
+ );
+
+ const submitCreate = async (event) => {
+ event.preventDefault();
+ const scope = actionAppCode;
+ if (!scope || scope !== appCode) {
+ showToast("应用已切换,请重新创建", "error");
+ return;
+ }
+ if (!targetUser?.userId) {
+ showToast("请先搜索并确认 App 用户", "error");
+ return;
+ }
+ if (targetUser.existingExternalAdminUser) {
+ showToast("该 App 用户已绑定外管账号", "error");
+ return;
+ }
+ let formPayload;
+ try {
+ formPayload = parseForm(externalAdminUserCreateFormSchema, createForm);
+ } catch (err) {
+ showToast(err.message || "创建参数不正确", "error");
+ return;
+ }
+
+ setLoadingAction("create");
+ try {
+ await createExternalAdminUser(scope, {
+ password: formPayload.password,
+ // 始终提交查询接口返回的稳定 userId,短 ID / 靓号只用于定位,避免后续改号造成错误绑定。
+ targetUserId: targetUser.userId,
+ username: formPayload.username,
+ });
+ if (currentAppCodeRef.current !== scope) {
+ return;
+ }
+ setCreateOpen(false);
+ setCreateFormState(emptyCreateForm());
+ setTargetUser(null);
+ setActionAppCode("");
+ showToast("外管用户已创建", "success");
+ await refreshList(scope);
+ } catch (err) {
+ if (currentAppCodeRef.current === scope) {
+ showToast(err.message || "创建外管用户失败", "error");
+ }
+ } finally {
+ if (currentAppCodeRef.current === scope) {
+ setLoadingAction("");
+ }
+ }
+ };
+
+ const toggleStatus = async (item) => {
+ const scope = appCode;
+ const nextStatus = item.status === "active" ? "disabled" : "active";
+ const actionLabel = nextStatus === "active" ? "启用" : "停用";
+ const ok = await confirm({
+ confirmText: actionLabel,
+ message: `${item.username || item.id} 的外管登录权限会立即${nextStatus === "active" ? "恢复" : "失效"}。`,
+ title: `${actionLabel}外管用户`,
+ tone: nextStatus === "active" ? "primary" : "danger",
+ });
+ if (!ok || currentAppCodeRef.current !== scope) {
+ return;
+ }
+
+ setLoadingAction(`status:${item.id}`);
+ try {
+ await updateExternalAdminUserStatus(scope, item.id, nextStatus);
+ if (currentAppCodeRef.current !== scope) {
+ return;
+ }
+ showToast(`外管用户已${actionLabel}`, "success");
+ await refreshList(scope);
+ } catch (err) {
+ if (currentAppCodeRef.current === scope) {
+ showToast(err.message || `${actionLabel}外管用户失败`, "error");
+ }
+ } finally {
+ if (currentAppCodeRef.current === scope) {
+ setLoadingAction("");
+ }
+ }
+ };
+
+ const openPassword = (item) => {
+ setPasswordUser(item);
+ setPasswordFormState(emptyPasswordForm());
+ setActionAppCode(appCode);
+ };
+ const closePassword = () => {
+ if (loadingAction.startsWith("password:")) {
+ return;
+ }
+ setPasswordUser(null);
+ setPasswordFormState(emptyPasswordForm());
+ setActionAppCode("");
+ };
+ const setPasswordForm = (patch) => {
+ setPasswordFormState((current) => ({ ...current, ...patch }));
+ };
+ const submitPassword = async (event) => {
+ event.preventDefault();
+ const scope = actionAppCode;
+ const item = passwordUser;
+ if (!item || !scope || scope !== appCode) {
+ showToast("应用已切换,请重新操作", "error");
+ return;
+ }
+ let payload;
+ try {
+ payload = parseForm(externalAdminUserPasswordFormSchema, passwordForm);
+ } catch (err) {
+ showToast(err.message || "密码参数不正确", "error");
+ return;
+ }
+
+ setLoadingAction(`password:${item.id}`);
+ try {
+ await resetExternalAdminUserPassword(scope, item.id, payload.password);
+ if (currentAppCodeRef.current !== scope) {
+ return;
+ }
+ setPasswordUser(null);
+ setPasswordFormState(emptyPasswordForm());
+ setActionAppCode("");
+ showToast("外管密码已重置", "success");
+ await refreshList(scope);
+ } catch (err) {
+ if (currentAppCodeRef.current === scope) {
+ showToast(err.message || "重置外管密码失败", "error");
+ }
+ } finally {
+ if (currentAppCodeRef.current === scope) {
+ setLoadingAction("");
+ }
+ }
+ };
+
+ return {
+ abilities,
+ closeCreate,
+ closePassword,
+ createForm,
+ createOpen,
+ data,
+ error,
+ keyword,
+ loading,
+ loadingAction,
+ lookupLoading,
+ lookupTarget,
+ openCreate,
+ openPassword,
+ page,
+ pageSize,
+ passwordForm,
+ passwordUser,
+ reload,
+ resetFilters,
+ setCreateForm,
+ setDisplayUserId: changeDisplayUserId,
+ setKeyword,
+ setPage,
+ setPasswordForm,
+ setStatus,
+ status,
+ submitCreate,
+ submitPassword,
+ targetUser,
+ toggleStatus,
+ };
+}
diff --git a/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx
new file mode 100644
index 0000000..1e25d6e
--- /dev/null
+++ b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx
@@ -0,0 +1,191 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, expect, test, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ abilities: { canCreate: true, canResetPassword: true, canStatus: true, canView: true },
+ appCode: "fami",
+ confirm: vi.fn(),
+ createExternalAdminUser: vi.fn(),
+ invalidateQueries: vi.fn(),
+ listExternalAdminUsers: vi.fn(),
+ lookupExternalAdminUserTarget: vi.fn(),
+ queryOptions: null,
+ queryPage: { items: [], page: 1, pageSize: 50, total: 0 },
+ reload: vi.fn(),
+ resetExternalAdminUserPassword: vi.fn(),
+ showToast: vi.fn(),
+ updateExternalAdminUserStatus: vi.fn(),
+}));
+
+vi.mock("@tanstack/react-query", () => ({
+ useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries }),
+}));
+
+vi.mock("@/app/app-scope/AppScopeProvider.jsx", () => ({
+ useAppScope: () => ({ appCode: mocks.appCode }),
+}));
+
+vi.mock("@/features/external-admin-users/api", () => ({
+ createExternalAdminUser: mocks.createExternalAdminUser,
+ listExternalAdminUsers: mocks.listExternalAdminUsers,
+ lookupExternalAdminUserTarget: mocks.lookupExternalAdminUserTarget,
+ resetExternalAdminUserPassword: mocks.resetExternalAdminUserPassword,
+ updateExternalAdminUserStatus: mocks.updateExternalAdminUserStatus,
+}));
+
+vi.mock("@/features/external-admin-users/permissions.js", () => ({
+ useExternalAdminUserAbilities: () => mocks.abilities,
+}));
+
+vi.mock("@/shared/hooks/useAdminQuery.js", () => ({
+ useAdminQuery: (_queryFn, options) => {
+ mocks.queryOptions = options;
+ return { data: mocks.queryPage, error: "", loading: false, reload: mocks.reload };
+ },
+}));
+
+vi.mock("@/shared/ui/ConfirmProvider.jsx", () => ({
+ useConfirm: () => mocks.confirm,
+}));
+
+vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
+ useToast: () => ({ showToast: mocks.showToast }),
+}));
+
+import { useExternalAdminUsersPage } from "./useExternalAdminUsersPage.js";
+
+beforeEach(() => {
+ mocks.appCode = "fami";
+ mocks.queryPage = { items: [], page: 1, pageSize: 50, total: 0 };
+ mocks.confirm.mockResolvedValue(true);
+ mocks.createExternalAdminUser.mockResolvedValue(externalUserFixture());
+ mocks.invalidateQueries.mockResolvedValue(undefined);
+ mocks.lookupExternalAdminUserTarget.mockResolvedValue(targetFixture());
+ mocks.reload.mockResolvedValue(undefined);
+ mocks.resetExternalAdminUserPassword.mockResolvedValue(externalUserFixture());
+ mocks.updateExternalAdminUserStatus.mockResolvedValue({ ...externalUserFixture(), status: "disabled" });
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+test("creates an App-scoped account with the canonical userId returned by lookup", async () => {
+ const { result } = renderHook(() => useExternalAdminUsersPage());
+
+ act(() => result.current.openCreate());
+ act(() => result.current.setDisplayUserId("VIP-1001"));
+ await act(async () => result.current.lookupTarget());
+ act(() =>
+ result.current.setCreateForm({
+ confirmPassword: "StrongPass123!",
+ password: "StrongPass123!",
+ username: "ops.fami",
+ }),
+ );
+ await act(async () => result.current.submitCreate({ preventDefault: vi.fn() }));
+
+ expect(mocks.lookupExternalAdminUserTarget).toHaveBeenCalledWith("fami", "VIP-1001");
+ expect(mocks.createExternalAdminUser).toHaveBeenCalledWith("fami", {
+ password: "StrongPass123!",
+ targetUserId: "internal-user-1001",
+ username: "ops.fami",
+ });
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ["external-admin-users", "fami"] });
+ expect(result.current.createOpen).toBe(false);
+});
+
+test("clears list filters, lookup results and password form when App scope changes", async () => {
+ const { result, rerender } = renderHook(() => useExternalAdminUsersPage());
+
+ act(() => result.current.setKeyword("old account"));
+ act(() => result.current.setStatus("active"));
+ act(() => result.current.openCreate());
+ act(() => result.current.setDisplayUserId("VIP-1001"));
+ await act(async () => result.current.lookupTarget());
+ act(() => result.current.openPassword(externalUserFixture()));
+ act(() => result.current.setPasswordForm({ confirmPassword: "OldPass123!", password: "OldPass123!" }));
+
+ mocks.appCode = "lalu";
+ rerender();
+
+ await waitFor(() => expect(result.current.keyword).toBe(""));
+ expect(result.current.status).toBe("");
+ expect(result.current.createOpen).toBe(false);
+ expect(result.current.createForm).toEqual({ confirmPassword: "", displayUserId: "", password: "", username: "" });
+ expect(result.current.targetUser).toBeNull();
+ expect(result.current.passwordUser).toBeNull();
+ expect(result.current.passwordForm).toEqual({ confirmPassword: "", password: "" });
+ expect(mocks.queryOptions.queryKey[1]).toBe("lalu");
+});
+
+test("ignores a late user lookup response after switching App", async () => {
+ let resolveLookup;
+ mocks.lookupExternalAdminUserTarget.mockImplementationOnce(
+ () => new Promise((resolve) => {
+ resolveLookup = resolve;
+ }),
+ );
+ const { result, rerender } = renderHook(() => useExternalAdminUsersPage());
+
+ act(() => result.current.openCreate());
+ act(() => result.current.setDisplayUserId("VIP-1001"));
+ let lookupPromise;
+ act(() => {
+ lookupPromise = result.current.lookupTarget();
+ });
+ mocks.appCode = "lalu";
+ rerender();
+ await waitFor(() => expect(result.current.createOpen).toBe(false));
+ await act(async () => {
+ resolveLookup(targetFixture());
+ await lookupPromise;
+ });
+
+ expect(result.current.targetUser).toBeNull();
+});
+
+test("refreshes the App-scoped list after status and password changes", async () => {
+ const item = externalUserFixture();
+ const { result } = renderHook(() => useExternalAdminUsersPage());
+
+ await act(async () => result.current.toggleStatus(item));
+ expect(mocks.updateExternalAdminUserStatus).toHaveBeenCalledWith("fami", 71, "disabled");
+
+ act(() => result.current.openPassword(item));
+ act(() => result.current.setPasswordForm({ confirmPassword: "NextPass123!", password: "NextPass123!" }));
+ await act(async () => result.current.submitPassword({ preventDefault: vi.fn() }));
+
+ expect(mocks.resetExternalAdminUserPassword).toHaveBeenCalledWith("fami", 71, "NextPass123!");
+ expect(mocks.invalidateQueries).toHaveBeenCalledTimes(2);
+ expect(mocks.invalidateQueries).toHaveBeenNthCalledWith(1, { queryKey: ["external-admin-users", "fami"] });
+ expect(mocks.invalidateQueries).toHaveBeenNthCalledWith(2, { queryKey: ["external-admin-users", "fami"] });
+});
+
+function targetFixture() {
+ return {
+ avatar: "https://cdn.example.com/user.png",
+ defaultDisplayUserId: "1001",
+ displayUserId: "VIP-1001",
+ prettyDisplayUserId: "VIP-1001",
+ prettyId: "pretty-1",
+ status: "active",
+ userId: "internal-user-1001",
+ username: "Fami user",
+ };
+}
+
+function externalUserFixture() {
+ return {
+ appCode: "fami",
+ createdAtMs: 1784073600000,
+ createdByAdminId: 9,
+ id: 71,
+ lastLoginAtMs: 1784077200000,
+ linkedUser: targetFixture(),
+ permissions: [],
+ status: "active",
+ updatedAtMs: 1784077200000,
+ username: "ops.fami",
+ };
+}
diff --git a/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx b/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx
new file mode 100644
index 0000000..a6db87d
--- /dev/null
+++ b/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx
@@ -0,0 +1,217 @@
+import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
+import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined";
+import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
+import { ExternalAdminUserFormDialog } from "@/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx";
+import { ExternalAdminUserPasswordDialog } from "@/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx";
+import styles from "@/features/external-admin-users/external-admin-users.module.css";
+import { useExternalAdminUsersPage } from "@/features/external-admin-users/hooks/useExternalAdminUsersPage.js";
+import {
+ AdminActionIconButton,
+ AdminFilterResetButton,
+ AdminFilterSelect,
+ AdminListBody,
+ AdminListPage,
+ AdminListToolbar,
+ AdminRowActions,
+ AdminSearchBox,
+} from "@/shared/ui/AdminListLayout.jsx";
+import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
+import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
+import { DataState } from "@/shared/ui/DataState.jsx";
+import { DataTable } from "@/shared/ui/DataTable.jsx";
+import { formatMillis } from "@/shared/utils/time.js";
+
+const statusOptions = [
+ ["", "全部状态"],
+ ["active", "启用"],
+ ["disabled", "停用"],
+];
+
+const columns = [
+ {
+ key: "linkedUser",
+ label: "绑定 App 用户",
+ render: (item) => (
+
+ ),
+ width: "minmax(250px, 1.2fr)",
+ },
+ {
+ key: "username",
+ label: "外管账号",
+ render: (item) => (
+
+ {item.username || "-"}
+ ID {item.id || "-"}
+
+ ),
+ width: "minmax(190px, 0.95fr)",
+ },
+ {
+ key: "status",
+ label: "状态",
+ render: (item, _index, context) => {
+ const page = context?.page;
+ // 外管状态和密码都是账号级敏感写操作;同页串行化可避免两个请求互相覆盖全局 loading 状态。
+ const accountActionInFlight = Boolean(page?.loadingAction);
+ return (
+ page.toggleStatus(item)}
+ />
+ );
+ },
+ width: "minmax(120px, 0.65fr)",
+ },
+ {
+ key: "permissions",
+ label: "外管权限",
+ render: (item) => (item.permissions?.length ? `${item.permissions.length} 项` : "-"),
+ width: "minmax(120px, 0.65fr)",
+ },
+ {
+ key: "createdByAdminId",
+ label: "创建人",
+ render: (item) => item.createdByAdminId || "-",
+ width: "minmax(130px, 0.7fr)",
+ },
+ {
+ key: "lastLoginAtMs",
+ label: "最近登录",
+ render: (item) => formatMillis(item.lastLoginAtMs),
+ width: "minmax(170px, 0.85fr)",
+ },
+ {
+ key: "createdAtMs",
+ label: "创建时间",
+ render: (item) => formatMillis(item.createdAtMs),
+ width: "minmax(170px, 0.85fr)",
+ },
+ {
+ key: "actions",
+ label: "操作",
+ render: (item, _index, context) => {
+ const page = context?.page;
+ return (
+
+ page.openPassword(item)}
+ >
+
+
+
+ );
+ },
+ resizable: false,
+ width: "88px",
+ },
+];
+
+export function ExternalAdminUsersPage() {
+ const page = useExternalAdminUsersPage();
+ const items = page.data.items || [];
+ const total = Number(page.data.total || 0);
+
+ return (
+
+
+
+
+
+ {page.abilities.canCreate ? (
+
+
+
+ ) : null}
+ >
+ }
+ filters={
+ <>
+
+
+
+ >
+ }
+ />
+
+
+ 0
+ ? {
+ onPageChange: page.setPage,
+ page: page.page,
+ pageSize: page.data.pageSize || page.pageSize,
+ total,
+ }
+ : undefined
+ }
+ rowKey={(item) => item.id || `${item.appCode}-${item.username}`}
+ />
+
+
+
+
+
+ );
+}
diff --git a/src/features/external-admin-users/permissions.js b/src/features/external-admin-users/permissions.js
new file mode 100644
index 0000000..0d01521
--- /dev/null
+++ b/src/features/external-admin-users/permissions.js
@@ -0,0 +1,13 @@
+import { useAuth } from "@/app/auth/AuthProvider.jsx";
+import { PERMISSIONS } from "@/app/permissions";
+
+export function useExternalAdminUserAbilities() {
+ const { can } = useAuth();
+
+ return {
+ canCreate: can(PERMISSIONS.externalAdminUserCreate),
+ canResetPassword: can(PERMISSIONS.externalAdminUserResetPassword),
+ canStatus: can(PERMISSIONS.externalAdminUserStatus),
+ canView: can(PERMISSIONS.externalAdminUserView),
+ };
+}
diff --git a/src/features/external-admin-users/routes.js b/src/features/external-admin-users/routes.js
new file mode 100644
index 0000000..f6adec2
--- /dev/null
+++ b/src/features/external-admin-users/routes.js
@@ -0,0 +1,13 @@
+import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
+
+export const externalAdminUserRoutes = [
+ {
+ label: "外管用户",
+ loader: () =>
+ import("./pages/ExternalAdminUsersPage.jsx").then((module) => module.ExternalAdminUsersPage),
+ menuCode: MENU_CODES.operationExternalAdminUsers,
+ pageKey: "operation-external-admin-users",
+ path: "/operations/external-admin-users",
+ permission: PERMISSIONS.externalAdminUserView,
+ },
+];
diff --git a/src/features/external-admin-users/schema.test.ts b/src/features/external-admin-users/schema.test.ts
new file mode 100644
index 0000000..ca1e605
--- /dev/null
+++ b/src/features/external-admin-users/schema.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, test } from "vitest";
+import {
+ externalAdminUserCreateFormSchema,
+ externalAdminUserLookupSchema,
+ externalAdminUserPasswordFormSchema,
+} from "./schema";
+
+describe("external admin user schemas", () => {
+ test("accepts Unicode pretty IDs and trims login identifiers", () => {
+ expect(externalAdminUserLookupSchema.parse({ displayUserId: " 靓号-一号 " })).toEqual({
+ displayUserId: "靓号-一号",
+ });
+
+ expect(
+ externalAdminUserCreateFormSchema.parse({
+ confirmPassword: " StrongPass123! ",
+ displayUserId: " VIP-1001 ",
+ password: " StrongPass123! ",
+ username: " ops.fami ",
+ }),
+ ).toEqual({
+ displayUserId: "VIP-1001",
+ password: " StrongPass123! ",
+ username: "ops.fami",
+ });
+ });
+
+ test("keeps password bytes unchanged and rejects mismatched confirmation", () => {
+ expect(
+ externalAdminUserPasswordFormSchema.parse({
+ confirmPassword: " password-with-spaces ",
+ password: " password-with-spaces ",
+ }),
+ ).toEqual({ password: " password-with-spaces " });
+
+ expect(() =>
+ externalAdminUserPasswordFormSchema.parse({
+ confirmPassword: "DifferentPass123!",
+ password: "StrongPass123!",
+ }),
+ ).toThrow("两次输入的密码不一致");
+ });
+
+ test("rejects all-whitespace credentials that cannot complete first-login rotation", () => {
+ const result = externalAdminUserCreateFormSchema.safeParse({
+ confirmPassword: " ",
+ displayUserId: "163000",
+ password: " ",
+ username: "fami-operator",
+ });
+
+ expect(result.success).toBe(false);
+ });
+
+ test("enforces the login account character set from the API contract", () => {
+ const result = externalAdminUserCreateFormSchema.safeParse({
+ confirmPassword: "StrongPass123!",
+ displayUserId: "1001",
+ password: "StrongPass123!",
+ username: "运营账号",
+ });
+
+ expect(result.success).toBe(false);
+ });
+});
diff --git a/src/features/external-admin-users/schema.ts b/src/features/external-admin-users/schema.ts
new file mode 100644
index 0000000..ca85633
--- /dev/null
+++ b/src/features/external-admin-users/schema.ts
@@ -0,0 +1,63 @@
+import { z } from "zod";
+
+const displayUserIdSchema = z
+ .string()
+ .trim()
+ .min(1, "请输入用户短 ID / 靓号")
+ .refine((value) => Array.from(value).length <= 64, "用户短 ID / 靓号不能超过 64 个字符")
+ // 靓号可能包含 Unicode 字形,不能收窄为纯数字;这里只拦截会破坏查询协议和日志的空白、控制字符。
+ .refine((value) => !/[\s\p{Cc}]/u.test(value), "用户短 ID / 靓号不能包含空白或控制字符");
+
+const usernameSchema = z
+ .string()
+ .trim()
+ .min(3, "外管账号至少 3 位")
+ .max(64, "外管账号不能超过 64 个字符")
+ // 外管系统使用独立登录标识;限制为跨浏览器、网关和审计日志均稳定的 ASCII 账号字符集。
+ .regex(/^[A-Za-z0-9._-]+$/, "外管账号只能包含字母、数字、点、下划线和连字符");
+
+// 密码属于登录凭证,不能 trim 或自动改写;前端只做长度与确认值校验,服务端会重复校验并仅持久化密码摘要。
+const passwordSchema = z
+ .string()
+ .min(8, "密码至少 8 位")
+ .max(72, "密码不能超过 72 个字符")
+ .refine((value) => value.trim().length > 0, "密码不能全部为空白字符");
+
+export const externalAdminUserLookupSchema = z.object({
+ displayUserId: displayUserIdSchema,
+});
+
+export const externalAdminUserCreateFormSchema = z
+ .object({
+ confirmPassword: passwordSchema,
+ displayUserId: displayUserIdSchema,
+ password: passwordSchema,
+ username: usernameSchema,
+ })
+ .superRefine((value, context) => {
+ if (value.password !== value.confirmPassword) {
+ context.addIssue({ code: "custom", message: "两次输入的密码不一致", path: ["confirmPassword"] });
+ }
+ })
+ .transform(({ confirmPassword: _confirmPassword, ...payload }) => payload);
+
+export const externalAdminUserPasswordFormSchema = z
+ .object({
+ confirmPassword: passwordSchema,
+ password: passwordSchema,
+ })
+ .superRefine((value, context) => {
+ if (value.password !== value.confirmPassword) {
+ context.addIssue({ code: "custom", message: "两次输入的密码不一致", path: ["confirmPassword"] });
+ }
+ })
+ .transform(({ confirmPassword: _confirmPassword, ...payload }) => payload);
+
+export const externalAdminUserStatusSchema = z.object({
+ status: z.enum(["active", "disabled"]),
+});
+
+export type ExternalAdminUserCreateForm = z.input;
+export type ExternalAdminUserCreatePayload = z.output;
+export type ExternalAdminUserPasswordForm = z.input;
+export type ExternalAdminUserPasswordPayload = z.output;
diff --git a/src/features/roles/hooks/useRolesPage.js b/src/features/roles/hooks/useRolesPage.js
index 9fc6878..6c0d0f0 100644
--- a/src/features/roles/hooks/useRolesPage.js
+++ b/src/features/roles/hooks/useRolesPage.js
@@ -32,7 +32,14 @@ const permissionGroupDefinitions = [
},
{
key: "operations",
- prefixes: ["coin-ledger", "coin-seller-ledger", "coin-adjustment", "report", "gift-diamond"],
+ prefixes: [
+ "coin-ledger",
+ "coin-seller-ledger",
+ "coin-adjustment",
+ "external-admin-user",
+ "report",
+ "gift-diamond",
+ ],
title: "运营管理",
},
{ key: "payment", prefixes: ["payment-bill", "payment-product", "payment-third-party"], title: "支付管理" },
diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts
index bf7e8e2..4ca64a5 100644
--- a/src/shared/api/generated/endpoints.ts
+++ b/src/shared/api/generated/endpoints.ts
@@ -52,6 +52,7 @@ export const API_OPERATIONS = {
createCountry: "createCountry",
createEmojiPack: "createEmojiPack",
createExploreTab: "createExploreTab",
+ createExternalAdminUser: "createExternalAdminUser",
createFinanceCoinSellerRechargeOrder: "createFinanceCoinSellerRechargeOrder",
createFullServerNoticeFanout: "createFullServerNoticeFanout",
createGift: "createGift",
@@ -210,6 +211,7 @@ export const API_OPERATIONS = {
listEmojiPackCategories: "listEmojiPackCategories",
listEmojiPacks: "listEmojiPacks",
listExploreTabs: "listExploreTabs",
+ listExternalAdminUsers: "listExternalAdminUsers",
listFinanceCoinSellerRechargeOrders: "listFinanceCoinSellerRechargeOrders",
listFinanceScopeAssignments: "listFinanceScopeAssignments",
listFinanceWithdrawalApplications: "listFinanceWithdrawalApplications",
@@ -281,6 +283,7 @@ export const API_OPERATIONS = {
login: "login",
logout: "logout",
lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget",
+ lookupExternalAdminUserTarget: "lookupExternalAdminUserTarget",
lookupResourceGrantTarget: "lookupResourceGrantTarget",
me: "me",
navigationMenus: "navigationMenus",
@@ -301,6 +304,7 @@ export const API_OPERATIONS = {
replaceRolePermissions: "replaceRolePermissions",
replaceUserAppScopes: "replaceUserAppScopes",
replaceUserFinanceScopes: "replaceUserFinanceScopes",
+ resetExternalAdminUserPassword: "resetExternalAdminUserPassword",
resetUserPassword: "resetUserPassword",
retryActivityTemplateRewardJob: "retryActivityTemplateRewardJob",
retryActivityTemplateTaskClaim: "retryActivityTemplateTaskClaim",
@@ -342,6 +346,7 @@ export const API_OPERATIONS = {
updateCumulativeRechargeRewardConfig: "updateCumulativeRechargeRewardConfig",
updateDiceConfig: "updateDiceConfig",
updateExploreTab: "updateExploreTab",
+ updateExternalAdminUserStatus: "updateExternalAdminUserStatus",
updateFirstRechargeRewardConfig: "updateFirstRechargeRewardConfig",
updateGift: "updateGift",
updateGiftType: "updateGiftType",
@@ -674,6 +679,13 @@ export const API_ENDPOINTS: Record = {
permission: "app-config:update",
permissions: ["app-config:update"]
},
+ createExternalAdminUser: {
+ method: "POST",
+ operationId: API_OPERATIONS.createExternalAdminUser,
+ path: "/v1/admin/external-admin-users",
+ permission: "external-admin-user:create",
+ permissions: ["external-admin-user:create"]
+ },
createFinanceCoinSellerRechargeOrder: {
method: "POST",
operationId: API_OPERATIONS.createFinanceCoinSellerRechargeOrder,
@@ -1778,6 +1790,13 @@ export const API_ENDPOINTS: Record = {
permission: "app-config:view",
permissions: ["app-config:view"]
},
+ listExternalAdminUsers: {
+ method: "GET",
+ operationId: API_OPERATIONS.listExternalAdminUsers,
+ path: "/v1/admin/external-admin-users",
+ permission: "external-admin-user:view",
+ permissions: ["external-admin-user:view"]
+ },
listFinanceCoinSellerRechargeOrders: {
method: "GET",
operationId: API_OPERATIONS.listFinanceCoinSellerRechargeOrders,
@@ -2269,6 +2288,13 @@ export const API_ENDPOINTS: Record = {
permission: "coin-adjustment:create",
permissions: ["coin-adjustment:create"]
},
+ lookupExternalAdminUserTarget: {
+ method: "GET",
+ operationId: API_OPERATIONS.lookupExternalAdminUserTarget,
+ path: "/v1/admin/external-admin-users/target",
+ permission: "external-admin-user:create",
+ permissions: ["external-admin-user:create"]
+ },
lookupResourceGrantTarget: {
method: "GET",
operationId: API_OPERATIONS.lookupResourceGrantTarget,
@@ -2403,6 +2429,13 @@ export const API_ENDPOINTS: Record = {
permission: "user:update",
permissions: ["user:update"]
},
+ resetExternalAdminUserPassword: {
+ method: "POST",
+ operationId: API_OPERATIONS.resetExternalAdminUserPassword,
+ path: "/v1/admin/external-admin-users/{id}/password",
+ permission: "external-admin-user:reset-password",
+ permissions: ["external-admin-user:reset-password"]
+ },
resetUserPassword: {
method: "POST",
operationId: API_OPERATIONS.resetUserPassword,
@@ -2688,6 +2721,13 @@ export const API_ENDPOINTS: Record = {
permission: "app-config:update",
permissions: ["app-config:update"]
},
+ updateExternalAdminUserStatus: {
+ method: "PATCH",
+ operationId: API_OPERATIONS.updateExternalAdminUserStatus,
+ path: "/v1/admin/external-admin-users/{id}/status",
+ permission: "external-admin-user:status",
+ permissions: ["external-admin-user:status"]
+ },
updateFirstRechargeRewardConfig: {
method: "PUT",
operationId: API_OPERATIONS.updateFirstRechargeRewardConfig,
diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts
index 0dadb3f..e3698b9 100644
--- a/src/shared/api/generated/schema.d.ts
+++ b/src/shared/api/generated/schema.d.ts
@@ -3596,6 +3596,70 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/admin/external-admin-users": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["listExternalAdminUsers"];
+ put?: never;
+ post: operations["createExternalAdminUser"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/admin/external-admin-users/target": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["lookupExternalAdminUserTarget"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/admin/external-admin-users/{id}/status": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch: operations["updateExternalAdminUserStatus"];
+ trace?: never;
+ };
+ "/admin/external-admin-users/{id}/password": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations["resetExternalAdminUserPassword"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/exports/app-users": {
parameters: {
query?: never;
@@ -4723,6 +4787,64 @@ export interface components {
ApiResponseAppUserAccessToken: components["schemas"]["Envelope"] & {
data?: components["schemas"]["AppUserAccessToken"];
};
+ ApiResponseExternalAdminUserPage: components["schemas"]["Envelope"] & {
+ data?: components["schemas"]["ApiPageExternalAdminUser"];
+ };
+ ApiResponseExternalAdminUser: components["schemas"]["Envelope"] & {
+ data?: components["schemas"]["ExternalAdminUser"];
+ };
+ ApiResponseExternalAdminUserTarget: components["schemas"]["Envelope"] & {
+ data?: components["schemas"]["ExternalAdminUserTargetLookup"];
+ };
+ ApiPageExternalAdminUser: {
+ items: components["schemas"]["ExternalAdminUser"][];
+ page: number;
+ pageSize: number;
+ /** Format: int64 */
+ total: number;
+ };
+ ExternalAdminUserTarget: {
+ userId: string;
+ displayUserId: string;
+ defaultDisplayUserId?: string;
+ prettyDisplayUserId?: string;
+ prettyId?: string;
+ username?: string;
+ avatar?: string;
+ status: string;
+ };
+ ExternalAdminUserTargetLookup: {
+ linkedUser: components["schemas"]["ExternalAdminUserTarget"];
+ existingExternalAdminUser?: components["schemas"]["ExternalAdminUser"];
+ };
+ ExternalAdminUser: {
+ id: number;
+ appCode: string;
+ username: string;
+ /** @enum {string} */
+ status: "active" | "disabled";
+ linkedUser: components["schemas"]["ExternalAdminUserTarget"];
+ permissions: string[];
+ createdByAdminId?: number;
+ /** Format: int64 */
+ lastLoginAtMs?: number | null;
+ /** Format: int64 */
+ createdAtMs: number;
+ /** Format: int64 */
+ updatedAtMs?: number;
+ };
+ ExternalAdminUserCreateInput: {
+ targetUserId: string;
+ username: string;
+ password: string;
+ };
+ ExternalAdminUserStatusInput: {
+ /** @enum {string} */
+ status: "active" | "disabled";
+ };
+ ExternalAdminUserPasswordInput: {
+ password: string;
+ };
ApiPageAppUser: {
items: components["schemas"]["AppUser"][];
page: number;
@@ -6882,6 +7004,33 @@ export interface components {
};
};
/** @description OK */
+ ExternalAdminUserPageResponse: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiResponseExternalAdminUserPage"];
+ };
+ };
+ /** @description OK */
+ ExternalAdminUserResponse: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiResponseExternalAdminUser"];
+ };
+ };
+ /** @description OK */
+ ExternalAdminUserTargetResponse: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiResponseExternalAdminUserTarget"];
+ };
+ };
+ /** @description OK */
AgencyPageResponse: {
headers: {
[name: string]: unknown;
@@ -12028,6 +12177,99 @@ export interface operations {
200: components["responses"]["AppUserLoginLogPageResponse"];
};
};
+ listExternalAdminUsers: {
+ parameters: {
+ query?: {
+ page?: components["parameters"]["Page"];
+ page_size?: components["parameters"]["PageSize"];
+ keyword?: components["parameters"]["Keyword"];
+ status?: components["parameters"]["Status"];
+ };
+ header: {
+ "X-App-Code": components["parameters"]["AppCodeHeader"];
+ };
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: components["responses"]["ExternalAdminUserPageResponse"];
+ };
+ };
+ createExternalAdminUser: {
+ parameters: {
+ query?: never;
+ header: {
+ "X-App-Code": components["parameters"]["AppCodeHeader"];
+ };
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ExternalAdminUserCreateInput"];
+ };
+ };
+ responses: {
+ 201: components["responses"]["ExternalAdminUserResponse"];
+ };
+ };
+ lookupExternalAdminUserTarget: {
+ parameters: {
+ query: {
+ display_user_id: string;
+ };
+ header: {
+ "X-App-Code": components["parameters"]["AppCodeHeader"];
+ };
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: components["responses"]["ExternalAdminUserTargetResponse"];
+ };
+ };
+ updateExternalAdminUserStatus: {
+ parameters: {
+ query?: never;
+ header: {
+ "X-App-Code": components["parameters"]["AppCodeHeader"];
+ };
+ path: {
+ id: components["parameters"]["Id"];
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ExternalAdminUserStatusInput"];
+ };
+ };
+ responses: {
+ 200: components["responses"]["ExternalAdminUserResponse"];
+ };
+ };
+ resetExternalAdminUserPassword: {
+ parameters: {
+ query?: never;
+ header: {
+ "X-App-Code": components["parameters"]["AppCodeHeader"];
+ };
+ path: {
+ id: components["parameters"]["Id"];
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ExternalAdminUserPasswordInput"];
+ };
+ };
+ responses: {
+ 200: components["responses"]["ExternalAdminUserResponse"];
+ };
+ };
appExportUsers: {
parameters: {
query?: {
diff --git a/vite.config.js b/vite.config.js
index 77e7ea9..bc6aeb6 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,7 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
-function redirectSubsystemEntry(req, res, next) {
+export function redirectSubsystemEntry(req, res, next) {
const rawUrl = req.url || "";
const [pathname, query = ""] = rawUrl.split("?");
@@ -19,7 +19,7 @@ function redirectSubsystemEntry(req, res, next) {
return;
}
- if (pathname === "/databi" || pathname === "/finance") {
+ if (pathname === "/databi" || pathname === "/finance" || pathname === "/external-admin") {
res.statusCode = 301;
res.setHeader("Location", `${pathname}/${query ? `?${query}` : ""}`);
res.end();
@@ -33,6 +33,16 @@ function redirectSubsystemEntry(req, res, next) {
return;
}
+ if (
+ pathname.startsWith("/external-admin/") &&
+ pathname !== "/external-admin/" &&
+ !pathname.startsWith("/external-admin/src/") &&
+ !pathname.split("/").at(-1).includes(".")
+ ) {
+ // Vite's generic SPA fallback points unknown paths at the main admin entry; keep external-admin deep links inside its isolated document.
+ req.url = `/external-admin/index.html${query ? `?${query}` : ""}`;
+ }
+
next();
}
@@ -68,6 +78,7 @@ export default defineConfig({
databi: new URL("./databi/index.html", import.meta.url).pathname,
databiSocial: new URL("./databi/social/index.html", import.meta.url).pathname,
finance: new URL("./finance/index.html", import.meta.url).pathname,
+ externalAdmin: new URL("./external-admin/index.html", import.meta.url).pathname,
moneyRedirect: new URL("./money/index.html", import.meta.url).pathname,
opsCenter: new URL("./ops-center/index.html", import.meta.url).pathname
}