feat(admin): add external management system
This commit is contained in:
parent
9045de6ad2
commit
bcc3854114
@ -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"],
|
||||
|
||||
@ -37,6 +37,15 @@ server {
|
||||
proxy_pass http://127.0.0.1:13100/readyz;
|
||||
}
|
||||
|
||||
location = /external-admin {
|
||||
return 301 /external-admin/$is_args$args;
|
||||
}
|
||||
|
||||
# 外管使用独立 HTML 入口;深链必须回退到自己的入口,不能落入主后台的 index.html。
|
||||
location ^~ /external-admin/ {
|
||||
try_files $uri $uri/ /external-admin/index.html;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
13
external-admin/index.html
Normal file
13
external-admin/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<title>HYApp 外管</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="external-admin-root"></div>
|
||||
<script type="module" src="/external-admin/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
53
external-admin/src/ExternalAdminApp.jsx
Normal file
53
external-admin/src/ExternalAdminApp.jsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { RequireExternalAuth, RequireExternalCapability, RequirePasswordChanged } from "./auth/RouteGuards.jsx";
|
||||
import { ExternalAdminLayout } from "./layout/ExternalAdminLayout.jsx";
|
||||
import { ExternalPageState } from "./shared/PageComponents.jsx";
|
||||
|
||||
const LoginPage = lazy(() => import("./pages/LoginPage.jsx"));
|
||||
const OverviewPage = lazy(() => import("./pages/OverviewPage.jsx"));
|
||||
const UsersPage = lazy(() => import("./pages/UsersPage.jsx"));
|
||||
const BansPage = lazy(() => import("./pages/BansPage.jsx"));
|
||||
const OrganizationPage = lazy(() => import("./pages/OrganizationPage.jsx"));
|
||||
const RoomsPage = lazy(() => import("./pages/RoomsPage.jsx"));
|
||||
const GrantsPage = lazy(() => import("./pages/GrantsPage.jsx"));
|
||||
const BannersPage = lazy(() => import("./pages/BannersPage.jsx"));
|
||||
const ChangePasswordPage = lazy(() => import("./pages/ChangePasswordPage.jsx"));
|
||||
|
||||
export function ExternalAdminApp() {
|
||||
return (
|
||||
<Suspense fallback={<ExternalPageState loading />}>
|
||||
<Routes>
|
||||
<Route element={<LoginPage />} path="/login" />
|
||||
<Route element={<RequireExternalAuth />}>
|
||||
<Route element={<ChangePasswordPage />} path="/change-password" />
|
||||
<Route element={<RequirePasswordChanged />}>
|
||||
<Route element={<ExternalAdminLayout />}>
|
||||
<Route element={<Navigate replace to="/overview" />} index />
|
||||
<Route element={<OverviewPage />} path="/overview" />
|
||||
<Route element={<RequireExternalCapability anyOf={["user:list", "user:update", "user:ban", "user-level:grant"]} />}>
|
||||
<Route element={<UsersPage />} path="/users" />
|
||||
</Route>
|
||||
<Route element={<RequireExternalCapability anyOf={["user-ban:list", "user:unban"]} />}>
|
||||
<Route element={<BansPage />} path="/bans" />
|
||||
</Route>
|
||||
<Route element={<RequireExternalCapability anyOf={["host:list", "agency:list", "bd:list", "bd-manager:list", "super-admin:list", "team:view"]} />}>
|
||||
<Route element={<OrganizationPage />} path="/organization/:kind" />
|
||||
</Route>
|
||||
<Route element={<RequireExternalCapability anyOf={["room:list", "room:update"]} />}>
|
||||
<Route element={<RoomsPage />} path="/rooms" />
|
||||
</Route>
|
||||
<Route element={<RequireExternalCapability anyOf={["privilege:list", "privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"]} />}>
|
||||
<Route element={<GrantsPage />} path="/grants" />
|
||||
</Route>
|
||||
<Route element={<RequireExternalCapability anyOf={["banner:create"]} />}>
|
||||
<Route element={<BannersPage />} path="/banners" />
|
||||
</Route>
|
||||
<Route element={<Navigate replace to="/overview" />} path="*" />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
94
external-admin/src/api/auth.js
Normal file
94
external-admin/src/api/auth.js
Normal file
@ -0,0 +1,94 @@
|
||||
import { externalRequest, rememberExternalCsrfToken } from "./client.js";
|
||||
import { arrayValue, asRecord, textValue } from "./normalizers.js";
|
||||
|
||||
// These external-session auth routes intentionally have no main-admin OpenAPI operation.
|
||||
const EXTERNAL_AUTH_PATHS = Object.freeze({
|
||||
apps: "/auth/apps",
|
||||
changePassword: "/auth/change-password",
|
||||
login: "/auth/login",
|
||||
logout: "/auth/logout",
|
||||
me: "/auth/me"
|
||||
});
|
||||
|
||||
export async function listExternalApps() {
|
||||
const data = await externalRequest(EXTERNAL_AUTH_PATHS.apps, { notifyUnauthorized: false });
|
||||
const source = asRecord(data);
|
||||
return arrayValue(data, source.items, source.apps)
|
||||
.map((item) => normalizeApp(item))
|
||||
.filter((item) => item.appCode);
|
||||
}
|
||||
|
||||
export async function loginExternal(payload) {
|
||||
const data = await externalRequest(EXTERNAL_AUTH_PATHS.login, {
|
||||
body: {
|
||||
account: String(payload.account || "").trim(),
|
||||
appCode: String(payload.appCode || "").trim(),
|
||||
password: String(payload.password || "")
|
||||
},
|
||||
method: "POST",
|
||||
notifyUnauthorized: false
|
||||
});
|
||||
rememberExternalCsrfToken(asRecord(data).csrfToken);
|
||||
return normalizeSession(data);
|
||||
}
|
||||
|
||||
export async function getExternalSession() {
|
||||
const data = await externalRequest(EXTERNAL_AUTH_PATHS.me, { notifyUnauthorized: false });
|
||||
rememberExternalCsrfToken(asRecord(data).csrfToken);
|
||||
return normalizeSession(data);
|
||||
}
|
||||
|
||||
export async function logoutExternal() {
|
||||
try {
|
||||
await externalRequest(EXTERNAL_AUTH_PATHS.logout, { method: "POST", notifyUnauthorized: false });
|
||||
} finally {
|
||||
rememberExternalCsrfToken("");
|
||||
}
|
||||
}
|
||||
|
||||
export async function changeExternalPassword(payload) {
|
||||
const data = await externalRequest(EXTERNAL_AUTH_PATHS.changePassword, {
|
||||
body: {
|
||||
currentPassword: payload.oldPassword,
|
||||
newPassword: payload.newPassword
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
return data ? normalizeSession(data) : null;
|
||||
}
|
||||
|
||||
export function normalizeSession(data) {
|
||||
const root = asRecord(data);
|
||||
const accountRecord = asRecord(root.account);
|
||||
const linkedUser = asRecord(root.user || root.linkedUser || root.linked_user);
|
||||
const legacyAccount = asRecord(root.externalAdminUser || root.external_admin_user);
|
||||
const app = asRecord(root.app || linkedUser.app || accountRecord.app);
|
||||
const capabilities = [
|
||||
...arrayValue(root.capabilities),
|
||||
...arrayValue(root.permissions),
|
||||
...arrayValue(accountRecord.capabilities, accountRecord.permissions),
|
||||
...arrayValue(linkedUser.capabilities, linkedUser.permissions)
|
||||
]
|
||||
.map(String)
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
account: textValue(accountRecord.username, accountRecord.account, legacyAccount.username, legacyAccount.account, root.accountName, typeof root.account === "string" ? root.account : "", root.username),
|
||||
appCode: textValue(root.appCode, root.app_code, app.appCode, app.app_code, accountRecord.appCode, accountRecord.app_code, linkedUser.appCode, linkedUser.app_code),
|
||||
appName: textValue(app.name, app.appName, app.app_name, root.appName, root.app_name),
|
||||
capabilities: [...new Set(capabilities)],
|
||||
displayName: textValue(linkedUser.displayName, linkedUser.display_name, linkedUser.name, linkedUser.username, accountRecord.username, legacyAccount.username, root.displayName),
|
||||
id: textValue(accountRecord.id, accountRecord.externalAdminUserId, accountRecord.external_admin_user_id, legacyAccount.id, root.accountId, root.account_id),
|
||||
passwordChangeRequired: Boolean(
|
||||
root.passwordChangeRequired ?? root.password_change_required ?? accountRecord.passwordChangeRequired ?? accountRecord.password_change_required
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeApp(value) {
|
||||
const item = typeof value === "string" ? { appCode: value } : asRecord(value);
|
||||
return {
|
||||
appCode: textValue(item.appCode, item.app_code, item.code, item.value),
|
||||
name: textValue(item.name, item.appName, item.app_name, item.label, item.appCode, item.code)
|
||||
};
|
||||
}
|
||||
63
external-admin/src/api/auth.test.js
Normal file
63
external-admin/src/api/auth.test.js
Normal file
@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { changeExternalPassword, listExternalApps, normalizeSession } from "./auth.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("normalizeSession", () => {
|
||||
test("normalizes account objects, fixed app and capabilities without stringifying objects", () => {
|
||||
expect(normalizeSession({
|
||||
account: { id: "ext-1", username: "fami-manager" },
|
||||
app: { appCode: "fami", appName: "Fami" },
|
||||
capabilities: ["user:list"],
|
||||
passwordChangeRequired: true
|
||||
})).toEqual(expect.objectContaining({
|
||||
account: "fami-manager",
|
||||
appCode: "fami",
|
||||
appName: "Fami",
|
||||
capabilities: ["user:list"],
|
||||
passwordChangeRequired: true
|
||||
}));
|
||||
});
|
||||
|
||||
test("merges permission aliases from root and bound user", () => {
|
||||
const session = normalizeSession({
|
||||
appCode: "lalu",
|
||||
permissions: ["host:list"],
|
||||
user: { permissions: ["team:view"], username: "lalu-ops" }
|
||||
});
|
||||
expect(session.capabilities).toEqual(["host:list", "team:view"]);
|
||||
});
|
||||
|
||||
test("keeps the external account separate from the linked App user", () => {
|
||||
const session = normalizeSession({
|
||||
account: { id: "external-account-8", username: "fami-manager" },
|
||||
app: { appCode: "fami", appName: "Fami" },
|
||||
user: { userId: "app-user-9", username: "App Nickname" }
|
||||
});
|
||||
expect(session.account).toBe("fami-manager");
|
||||
expect(session.displayName).toBe("App Nickname");
|
||||
expect(session.id).toBe("external-account-8");
|
||||
});
|
||||
});
|
||||
|
||||
describe("external auth API contract", () => {
|
||||
test("reads appName from auth/apps items", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ code: 0, data: { items: [{ appCode: "fami", appName: "Fami" }], total: 1 } })));
|
||||
await expect(listExternalApps()).resolves.toEqual([{ appCode: "fami", name: "Fami" }]);
|
||||
});
|
||||
|
||||
test("change-password sends currentPassword and never oldPassword", async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: {} }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await changeExternalPassword({ newPassword: "new-password", oldPassword: "current-password" });
|
||||
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ currentPassword: "current-password", newPassword: "new-password" });
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status });
|
||||
}
|
||||
321
external-admin/src/api/business.js
Normal file
321
external-admin/src/api/business.js
Normal file
@ -0,0 +1,321 @@
|
||||
import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
|
||||
import { externalRequest } from "./client.js";
|
||||
import { asRecord, booleanValue, entityId, normalizePage, numberValue, textValue } from "./normalizers.js";
|
||||
|
||||
// my-team is external-session scoped and is not part of the main-admin OpenAPI contract yet.
|
||||
const EXTERNAL_ONLY_PATHS = Object.freeze({ myTeamAgencies: "/admin/my-team/agencies" });
|
||||
|
||||
export function listUsers(query) {
|
||||
return externalRequest(externalOperationPath(API_OPERATIONS.appListUsers), { query }).then((data) => normalizePage(data, normalizeUser));
|
||||
}
|
||||
|
||||
export function listBannedUsers(query) {
|
||||
return externalRequest(externalOperationPath(API_OPERATIONS.appListBannedUsers), { query }).then((data) => normalizePage(data, normalizeBannedUser));
|
||||
}
|
||||
|
||||
export function updateUser(userId, payload) {
|
||||
return externalRequest(externalOperationPath(API_OPERATIONS.appUpdateUser, { id: userId }), { body: payload, method: "PATCH" });
|
||||
}
|
||||
|
||||
export function banUser(userId, payload) {
|
||||
return externalRequest(externalOperationPath(API_OPERATIONS.appBanUser, { id: userId }), { body: payload, method: "POST" });
|
||||
}
|
||||
|
||||
export function unbanUser(userId, payload = {}) {
|
||||
return externalRequest(externalOperationPath(API_OPERATIONS.appUnbanUser, { id: userId }), { body: payload, method: "POST" });
|
||||
}
|
||||
|
||||
export function updateUserLevels(userId, payload) {
|
||||
return externalRequest(externalOperationPath(API_OPERATIONS.appAdjustUserLevels, { id: userId }), { body: payload, method: "PUT" });
|
||||
}
|
||||
|
||||
export async function grantUserVip(targetUserId, payload) {
|
||||
const [programData, levelsData] = await Promise.all([
|
||||
externalRequest(externalOperationPath(API_OPERATIONS.getVipProgram)),
|
||||
externalRequest(externalOperationPath(API_OPERATIONS.getVipConfig))
|
||||
]);
|
||||
const programRoot = asRecord(programData);
|
||||
const program = asRecord(programRoot.config || programRoot.programConfig || programRoot.program_config || programRoot);
|
||||
const levelsRoot = asRecord(levelsData);
|
||||
const levels = Array.isArray(levelsData) ? levelsData : levelsRoot.levels || [];
|
||||
const level = Number(payload.level);
|
||||
|
||||
if (program.status === "disabled") {
|
||||
throw new Error("当前 App 的 VIP 体系已停用");
|
||||
}
|
||||
if (Array.isArray(levels) && levels.length && !levels.some((item) => Number(item.level) === level)) {
|
||||
throw new Error("请选择当前 App 已配置的 VIP 等级");
|
||||
}
|
||||
|
||||
const command = {
|
||||
commandId: payload.commandId,
|
||||
level,
|
||||
reason: payload.reason,
|
||||
targetUserId
|
||||
};
|
||||
if (program.grantMode === "trial_card" || program.grant_mode === "trial_card") {
|
||||
return externalRequest(externalOperationPath(API_OPERATIONS.grantVipTrialCard), {
|
||||
body: { ...command, durationMs: Number(payload.durationMs) },
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
return externalRequest(externalOperationPath(API_OPERATIONS.grantVip), { body: command, method: "POST" });
|
||||
}
|
||||
|
||||
export function listOrganizations(kind, query = {}) {
|
||||
const routes = {
|
||||
agencies: externalOperationPath(API_OPERATIONS.listAgencies),
|
||||
bds: externalOperationPath(API_OPERATIONS.listBDs),
|
||||
"bd-leaders": externalOperationPath(API_OPERATIONS.listManagers),
|
||||
hosts: externalOperationPath(API_OPERATIONS.listHosts),
|
||||
managers: externalOperationPath(API_OPERATIONS.listManagers),
|
||||
"super-admins": externalOperationPath(API_OPERATIONS.listBDLeaders),
|
||||
team: EXTERNAL_ONLY_PATHS.myTeamAgencies
|
||||
};
|
||||
if (!routes[kind]) {
|
||||
throw new Error("该组织列表尚无安全的数据接口");
|
||||
}
|
||||
const scopedQuery = kind === "super-admins" ? { ...query, status: query.status || "active" } : query;
|
||||
return externalRequest(routes[kind], { query: scopedQuery }).then((data) => {
|
||||
const page = normalizePage(data, normalizeOrganization);
|
||||
if (kind !== "team") {
|
||||
return page;
|
||||
}
|
||||
const source = asRecord(data);
|
||||
return {
|
||||
...page,
|
||||
totalBdLeaders: numberValue(source.totalBdLeaders ?? source.total_bd_leaders),
|
||||
totalBds: numberValue(source.totalBds ?? source.total_bds),
|
||||
truncated: booleanValue(source.truncated)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function listRooms(query) {
|
||||
return externalRequest(externalOperationPath(API_OPERATIONS.listRooms), { query }).then((data) => normalizePage(data, normalizeRoom));
|
||||
}
|
||||
|
||||
export function updateRoom(roomId, payload) {
|
||||
return externalRequest(externalOperationPath(API_OPERATIONS.updateRoom, { room_id: roomId }), { body: buildRoomUpdatePayload(payload), method: "PATCH" });
|
||||
}
|
||||
|
||||
export function buildRoomUpdatePayload(payload) {
|
||||
const regionText = String(payload.visibleRegionId ?? "").trim();
|
||||
const visibleRegionId = regionText ? Number(regionText) : 0;
|
||||
if (!Number.isSafeInteger(visibleRegionId) || visibleRegionId < 0) {
|
||||
throw new Error("可见区域 ID 必须是安全的正整数");
|
||||
}
|
||||
return {
|
||||
coverUrl: String(payload.coverUrl || "").trim(),
|
||||
description: String(payload.description || "").trim(),
|
||||
title: String(payload.title || "").trim(),
|
||||
visibleRegionId
|
||||
};
|
||||
}
|
||||
|
||||
export function listResources(query) {
|
||||
return externalRequest(externalOperationPath(API_OPERATIONS.listResources), { query }).then((data) => normalizePage(data, normalizeResource));
|
||||
}
|
||||
|
||||
export function listResourceGrants(query) {
|
||||
return externalRequest(externalOperationPath(API_OPERATIONS.listResourceGrants), { query }).then((data) => normalizePage(data));
|
||||
}
|
||||
|
||||
export async function lookupGrantTarget(userIdOrPrettyId) {
|
||||
const data = await externalRequest(externalOperationPath(API_OPERATIONS.lookupResourceGrantTarget), { query: { user_id: userIdOrPrettyId } });
|
||||
const source = asRecord(data);
|
||||
const user = asRecord(source.user || source.targetUser || source.target_user);
|
||||
return {
|
||||
id: textValue(source.targetUserId, source.target_user_id, source.userId, source.user_id, user.id, user.userId, user.user_id),
|
||||
prettyId: textValue(source.prettyId, source.pretty_id, user.prettyId, user.pretty_id, user.displayUserId, user.display_user_id),
|
||||
username: textValue(source.username, source.nickname, user.username, user.nickname)
|
||||
};
|
||||
}
|
||||
|
||||
export function grantResource(payload) {
|
||||
return externalRequest(externalOperationPath(API_OPERATIONS.grantResource), { body: payload, method: "POST" });
|
||||
}
|
||||
|
||||
export function grantPrettyId(payload) {
|
||||
return externalRequest(externalOperationPath(API_OPERATIONS.grantPrettyId), { body: payload, method: "POST" });
|
||||
}
|
||||
|
||||
export function listBanners(query) {
|
||||
return externalRequest(externalOperationPath(API_OPERATIONS.listBanners), { query }).then((data) => normalizePage(data, normalizeBanner));
|
||||
}
|
||||
|
||||
export function createBanner(payload) {
|
||||
return externalRequest(externalOperationPath(API_OPERATIONS.createBanner), { body: buildBannerPayload(payload), method: "POST" });
|
||||
}
|
||||
|
||||
export function buildBannerPayload(payload) {
|
||||
if (!["h5", "app"].includes(payload.bannerType)) {
|
||||
throw new Error("Banner 类型不正确");
|
||||
}
|
||||
if (!["android", "ios"].includes(payload.platform)) {
|
||||
throw new Error("Banner 平台不正确");
|
||||
}
|
||||
if (!["active", "disabled", "expired"].includes(payload.status)) {
|
||||
throw new Error("Banner 状态不正确");
|
||||
}
|
||||
const displayScopes = [...new Set((payload.displayScopes || []).filter((scope) => ["home", "room", "recharge", "me"].includes(scope)))];
|
||||
if (!displayScopes.length) {
|
||||
throw new Error("请至少选择一个展示范围");
|
||||
}
|
||||
if (!payload.coverUrl) {
|
||||
throw new Error("请上传 Banner 图片");
|
||||
}
|
||||
if (payload.bannerType === "h5") {
|
||||
let target;
|
||||
try {
|
||||
target = new URL(payload.param);
|
||||
} catch {
|
||||
throw new Error("请填写合法的 H5 链接");
|
||||
}
|
||||
if (!["http:", "https:"].includes(target.protocol)) {
|
||||
throw new Error("H5 链接仅支持 http 或 https");
|
||||
}
|
||||
}
|
||||
if (payload.bannerType === "app") {
|
||||
const validation = validatePublicAppJumpParam(payload.param);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.message);
|
||||
}
|
||||
}
|
||||
if (displayScopes.includes("room") && !payload.roomSmallImageUrl) {
|
||||
throw new Error("房间范围必须上传房间内小屏图");
|
||||
}
|
||||
const countryCode = String(payload.countryCode || "").trim().toUpperCase();
|
||||
if (countryCode && !/^[A-Z]{2,3}$/.test(countryCode)) {
|
||||
throw new Error("国家码必须是 2-3 位字母");
|
||||
}
|
||||
const regionId = Number(payload.regionId || 0);
|
||||
if (!Number.isSafeInteger(regionId) || regionId < 0) {
|
||||
throw new Error("区域 ID 不正确");
|
||||
}
|
||||
const startsAtMs = Number(payload.startsAtMs || 0);
|
||||
const endsAtMs = Number(payload.endsAtMs || 0);
|
||||
if (startsAtMs > 0 && endsAtMs > 0 && startsAtMs >= endsAtMs) {
|
||||
throw new Error("投放结束时间必须晚于开始时间");
|
||||
}
|
||||
|
||||
return {
|
||||
bannerType: payload.bannerType,
|
||||
countryCode,
|
||||
coverUrl: payload.coverUrl,
|
||||
description: String(payload.description || "").trim(),
|
||||
displayScope: displayScopes[0],
|
||||
displayScopes,
|
||||
endsAtMs,
|
||||
param: String(payload.param || "").trim(),
|
||||
platform: payload.platform,
|
||||
regionId,
|
||||
roomSmallImageUrl: displayScopes.includes("room") ? payload.roomSmallImageUrl : "",
|
||||
sortOrder: Number(payload.sortOrder || 0),
|
||||
startsAtMs,
|
||||
status: payload.status
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadExternalImage(file) {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
const data = await externalRequest(externalOperationPath(API_OPERATIONS.uploadImage), { body, method: "POST" });
|
||||
const source = asRecord(data);
|
||||
return textValue(source.url, source.fileUrl, source.file_url, asRecord(source.file).url);
|
||||
}
|
||||
|
||||
export function normalizeUser(item) {
|
||||
const levels = asRecord(item.levels);
|
||||
const wealth = asRecord(levels.wealth);
|
||||
const charm = asRecord(levels.charm);
|
||||
const vip = asRecord(item.vip);
|
||||
const ban = asRecord(item.ban);
|
||||
return {
|
||||
avatar: textValue(item.avatar, item.avatarUrl, item.avatar_url),
|
||||
banned: booleanValue(ban.active, item.banned, item.isBanned, item.is_banned) || ["banned", "disabled"].includes(String(item.status).toLowerCase()),
|
||||
charmLevel: numberValue(item.charmLevel ?? item.charm_level ?? charm.displayLevel ?? charm.display_level ?? charm.level),
|
||||
country: textValue(item.country, item.countryCode, item.country_code),
|
||||
gender: textValue(item.gender),
|
||||
id: entityId(item),
|
||||
prettyId: textValue(item.prettyId, item.pretty_id, item.displayUserId, item.display_user_id, item.shortId, item.short_id),
|
||||
status: textValue(item.status, item.banned ? "banned" : "active"),
|
||||
username: textValue(item.username, item.nickname, item.nickName, item.name),
|
||||
vipLevel: numberValue(item.vipLevel ?? item.vip_level ?? vip.level),
|
||||
wealthLevel: numberValue(item.wealthLevel ?? item.wealth_level ?? wealth.displayLevel ?? wealth.display_level ?? wealth.level)
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBannedUser(item) {
|
||||
// Ban-list rows use the ban-log id as `id`; unban must always target the nested App user id instead.
|
||||
const target = asRecord(item.target || item.user || item.targetUser || item.target_user);
|
||||
const user = normalizeUser(target);
|
||||
return {
|
||||
...user,
|
||||
banned: true,
|
||||
bannedAtMs: numberValue(item.bannedAtMs ?? item.banned_at_ms ?? item.createdAtMs ?? item.created_at_ms),
|
||||
banLogId: textValue(item.id, item.banLogId, item.ban_log_id),
|
||||
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
|
||||
id: textValue(target.userId, target.user_id, target.id),
|
||||
prettyId: textValue(target.displayUserId, target.display_user_id, target.prettyId, target.pretty_id),
|
||||
reason: textValue(item.reason)
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOrganization(item) {
|
||||
const user = asRecord(item.user || item.profile || item.manager);
|
||||
const agency = Boolean(item.agencyId ?? item.agency_id);
|
||||
return {
|
||||
avatar: textValue(agency ? item.ownerAvatar : "", agency ? item.owner_avatar : "", item.avatar, item.avatarUrl, item.avatar_url, user.avatar),
|
||||
appCode: textValue(item.appCode, item.app_code),
|
||||
country: textValue(item.regionName, item.region_name, item.country, item.countryCode, item.country_code, user.regionName, user.region_name, user.country),
|
||||
id: textValue(item.agencyId, item.agency_id, item.userId, item.user_id, item.id, entityId(user)),
|
||||
name: textValue(item.name, agency ? item.ownerUsername : "", item.displayName, item.display_name, item.username, user.username, user.name),
|
||||
parentBdUserId: textValue(item.parentBdUserId, item.parent_bd_user_id),
|
||||
prettyId: textValue(agency ? item.ownerDisplayUserId : "", agency ? item.owner_display_user_id : "", item.displayUserId, item.display_user_id, item.prettyId, item.pretty_id, user.displayUserId, user.display_user_id, user.prettyId, user.pretty_id),
|
||||
status: textValue(item.status, item.enabled === false ? "disabled" : "active"),
|
||||
username: textValue(agency ? item.ownerUsername : "", agency ? item.owner_username : "", item.username, user.username, user.nickname),
|
||||
userId: textValue(agency ? item.ownerUserId : "", agency ? item.owner_user_id : "", item.userId, item.user_id, user.userId, user.user_id)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRoom(item) {
|
||||
return {
|
||||
coverUrl: textValue(item.coverUrl, item.cover_url, item.backgroundUrl, item.background_url),
|
||||
description: textValue(item.description, item.introduction),
|
||||
id: entityId(item),
|
||||
ownerName: textValue(item.ownerName, item.owner_name, asRecord(item.owner).username),
|
||||
status: textValue(item.status, item.liveStatus, item.live_status),
|
||||
title: textValue(item.title, item.name, item.roomName, item.room_name),
|
||||
visibleRegionId: textValue(item.visibleRegionId, item.visible_region_id)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeResource(item) {
|
||||
return {
|
||||
coverUrl: textValue(item.coverUrl, item.cover_url, item.iconUrl, item.icon_url),
|
||||
id: textValue(item.resourceId, item.resource_id, item.id),
|
||||
name: textValue(item.name, item.resourceName, item.resource_name, item.resourceCode, item.resource_code),
|
||||
status: textValue(item.status),
|
||||
type: textValue(item.resourceType, item.resource_type, item.type)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBanner(item) {
|
||||
return {
|
||||
bannerType: textValue(item.bannerType, item.banner_type, item.type),
|
||||
coverUrl: textValue(item.coverUrl, item.cover_url, item.imageUrl, item.image_url),
|
||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||
description: textValue(item.description, item.title),
|
||||
id: textValue(item.id, item.bannerId, item.banner_id),
|
||||
platform: textValue(item.platform),
|
||||
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
||||
status: textValue(item.status)
|
||||
};
|
||||
}
|
||||
|
||||
function externalOperationPath(operation, params) {
|
||||
// Generated main-admin paths start with /v1; the external gateway already owns that version prefix in its base URL.
|
||||
return apiEndpointPath(operation, params).replace(/^\/v1(?=\/)/, "");
|
||||
}
|
||||
187
external-admin/src/api/business.test.js
Normal file
187
external-admin/src/api/business.test.js
Normal file
@ -0,0 +1,187 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { buildBannerPayload, buildRoomUpdatePayload, createBanner, grantUserVip, listOrganizations, normalizeBannedUser, normalizeOrganization, normalizeUser, unbanUser } from "./business.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("normalizeBannedUser", () => {
|
||||
test("uses nested target user id instead of ban log id for unban actions", () => {
|
||||
const item = normalizeBannedUser({
|
||||
id: "ban-log-9001",
|
||||
reason: "spam",
|
||||
target: {
|
||||
avatar: "https://cdn.test/avatar.png",
|
||||
displayUserId: "163000",
|
||||
userId: "318705991371722752",
|
||||
username: "Fami User"
|
||||
}
|
||||
});
|
||||
|
||||
expect(item.id).toBe("318705991371722752");
|
||||
expect(item.banLogId).toBe("ban-log-9001");
|
||||
expect(item.prettyId).toBe("163000");
|
||||
expect(item.reason).toBe("spam");
|
||||
});
|
||||
|
||||
test("unban request uses the normalized target user id", async () => {
|
||||
const user = normalizeBannedUser({ id: "ban-log-1", target: { userId: "canonical-user-2" } });
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: {} }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await unbanUser(user.id, { reason: "manual" });
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/external/app/users/canonical-user-2/unban");
|
||||
});
|
||||
});
|
||||
|
||||
describe("room API payload", () => {
|
||||
test("converts region id to int64-compatible number and maps empty to all regions", () => {
|
||||
expect(buildRoomUpdatePayload({ coverUrl: " cover ", description: " desc ", title: " Room ", visibleRegionId: "18" }))
|
||||
.toEqual({ coverUrl: "cover", description: "desc", title: "Room", visibleRegionId: 18 });
|
||||
expect(buildRoomUpdatePayload({ visibleRegionId: "" }).visibleRegionId).toBe(0);
|
||||
expect(() => buildRoomUpdatePayload({ visibleRegionId: "1.5" })).toThrow("可见区域 ID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("real external DTO normalization", () => {
|
||||
test("reads nested levels, vip and ban state from the App user DTO", () => {
|
||||
const user = normalizeUser({
|
||||
ban: { active: true },
|
||||
levels: { charm: { displayLevel: 8 }, wealth: { displayLevel: 12 } },
|
||||
userId: "user-1",
|
||||
vip: { level: 4 }
|
||||
});
|
||||
expect(user).toEqual(expect.objectContaining({ banned: true, charmLevel: 8, vipLevel: 4, wealthLevel: 12 }));
|
||||
});
|
||||
|
||||
test("normalizes agency owner fields and ordinary member fields", () => {
|
||||
expect(normalizeOrganization({
|
||||
agencyId: "agency-1",
|
||||
name: "Fami Guild",
|
||||
ownerAvatar: "owner.png",
|
||||
ownerDisplayUserId: "163000",
|
||||
ownerUserId: "owner-9",
|
||||
ownerUsername: "Guild Owner",
|
||||
regionName: "Middle East"
|
||||
})).toEqual(expect.objectContaining({
|
||||
avatar: "owner.png",
|
||||
id: "agency-1",
|
||||
name: "Fami Guild",
|
||||
prettyId: "163000",
|
||||
userId: "owner-9",
|
||||
username: "Guild Owner"
|
||||
}));
|
||||
expect(normalizeOrganization({ displayUserId: "9988", regionName: "SEA", userId: "bd-1", username: "BD One" }))
|
||||
.toEqual(expect.objectContaining({ id: "bd-1", name: "BD One", prettyId: "9988" }));
|
||||
});
|
||||
|
||||
test("maps BD Manager, Super Admin and team to their real scoped endpoints", async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { items: [], total: 0 } }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await listOrganizations("bd-leaders", { page: 1 });
|
||||
await listOrganizations("super-admins", { page: 1 });
|
||||
await listOrganizations("team", { page: 1 });
|
||||
|
||||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||||
"/api/v1/external/admin/managers?page=1",
|
||||
"/api/v1/external/admin/bd-leaders?page=1&status=active",
|
||||
"/api/v1/external/admin/my-team/agencies?page=1"
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves scoped team totals and truncation metadata", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({
|
||||
code: 0,
|
||||
data: {
|
||||
agencies: [{ agencyId: "agency-9", ownerUserId: "owner-9", parentBdUserId: "bd-7", status: "active" }],
|
||||
total: 1,
|
||||
totalBdLeaders: 2,
|
||||
totalBds: 7,
|
||||
truncated: true
|
||||
}
|
||||
})));
|
||||
|
||||
const team = await listOrganizations("team", { page: 1 });
|
||||
|
||||
expect(team).toEqual(expect.objectContaining({ total: 1, totalBdLeaders: 2, totalBds: 7, truncated: true }));
|
||||
expect(team.items[0]).toEqual(expect.objectContaining({ id: "agency-9", parentBdUserId: "bd-7", userId: "owner-9" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("banner API payload", () => {
|
||||
test("uses only the real appconfig banner enums and room image contract", () => {
|
||||
expect(buildBannerPayload({
|
||||
bannerType: "h5",
|
||||
countryCode: "sa",
|
||||
coverUrl: "https://cdn.test/banner.webp",
|
||||
description: "Campaign",
|
||||
displayScopes: ["home", "room"],
|
||||
param: "https://fami.test/campaign",
|
||||
platform: "android",
|
||||
regionId: 8,
|
||||
roomSmallImageUrl: "https://cdn.test/room.webp",
|
||||
sortOrder: 2,
|
||||
status: "active"
|
||||
})).toEqual(expect.objectContaining({
|
||||
bannerType: "h5",
|
||||
countryCode: "SA",
|
||||
displayScope: "home",
|
||||
displayScopes: ["home", "room"],
|
||||
platform: "android",
|
||||
roomSmallImageUrl: "https://cdn.test/room.webp",
|
||||
status: "active"
|
||||
}));
|
||||
expect(() => buildBannerPayload({ bannerType: "link", coverUrl: "x", displayScopes: ["home"], platform: "all", status: "enabled" }))
|
||||
.toThrow("Banner 类型不正确");
|
||||
});
|
||||
|
||||
test("create API sends the normalized appconfig payload", async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: {} }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await createBanner({
|
||||
bannerType: "app",
|
||||
coverUrl: "https://cdn.test/banner.webp",
|
||||
displayScopes: ["me"],
|
||||
param: JSON.stringify({ type: "wallet" }),
|
||||
platform: "ios",
|
||||
status: "disabled"
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/external/admin/app-config/banners");
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual(expect.objectContaining({
|
||||
bannerType: "app",
|
||||
displayScope: "me",
|
||||
displayScopes: ["me"],
|
||||
platform: "ios",
|
||||
status: "disabled"
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("VIP grant routing", () => {
|
||||
test("routes trial-card VIP grants to vipconfig and never sends track=vip to app users", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ code: 0, data: { config: { grantMode: "trial_card", status: "active" } } }))
|
||||
.mockResolvedValueOnce(jsonResponse({ code: 0, data: { levels: [{ level: 5 }] } }))
|
||||
.mockResolvedValueOnce(jsonResponse({ code: 0, data: {} }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await grantUserVip("user-9", { commandId: "vip-test", durationMs: 604800000, level: 5, reason: "manual" });
|
||||
|
||||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||||
"/api/v1/external/admin/activity/vip-program",
|
||||
"/api/v1/external/admin/activity/vip-levels",
|
||||
"/api/v1/external/admin/activity/vip-trial-card-grants"
|
||||
]);
|
||||
const payload = JSON.parse(fetchMock.mock.calls[2][1].body);
|
||||
expect(payload).toEqual(expect.objectContaining({ durationMs: 604800000, level: 5, targetUserId: "user-9" }));
|
||||
expect(payload).not.toHaveProperty("track");
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status });
|
||||
}
|
||||
149
external-admin/src/api/client.js
Normal file
149
external-admin/src/api/client.js
Normal file
@ -0,0 +1,149 @@
|
||||
const EXTERNAL_API_BASE = "/api/v1/external";
|
||||
const CSRF_COOKIE_NAME = "hyapp_external_csrf";
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
let unauthorizedHandler = null;
|
||||
let inMemoryCsrfToken = "";
|
||||
|
||||
export class ExternalApiError extends Error {
|
||||
constructor(message, { code = "", status = 0, details = null } = {}) {
|
||||
super(message);
|
||||
this.name = "ExternalApiError";
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function setExternalUnauthorizedHandler(handler) {
|
||||
unauthorizedHandler = typeof handler === "function" ? handler : null;
|
||||
return () => {
|
||||
if (unauthorizedHandler === handler) {
|
||||
unauthorizedHandler = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function rememberExternalCsrfToken(token) {
|
||||
// Cookie remains the source of truth. The memory copy only covers deployments where Cookie Path prevents page JavaScript from reading it.
|
||||
inMemoryCsrfToken = String(token || "").trim();
|
||||
}
|
||||
|
||||
export function readExternalCsrfToken(cookieText = globalThis.document?.cookie || "") {
|
||||
const prefix = `${CSRF_COOKIE_NAME}=`;
|
||||
const cookieValue = String(cookieText)
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(prefix));
|
||||
|
||||
if (!cookieValue) {
|
||||
return inMemoryCsrfToken;
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(cookieValue.slice(prefix.length));
|
||||
} catch {
|
||||
return cookieValue.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
|
||||
export async function externalRequest(path, options = {}) {
|
||||
const method = String(options.method || "GET").toUpperCase();
|
||||
const headers = new Headers(options.headers || {});
|
||||
const body = createBody(options.body, headers);
|
||||
|
||||
if (!SAFE_METHODS.has(method)) {
|
||||
const csrfToken = readExternalCsrfToken();
|
||||
if (csrfToken) {
|
||||
headers.set("X-CSRF-Token", csrfToken);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(path, options.query), {
|
||||
body,
|
||||
credentials: "include",
|
||||
headers,
|
||||
method,
|
||||
signal: options.signal
|
||||
});
|
||||
const payload = await readPayload(response);
|
||||
|
||||
if (response.status === 401 && options.notifyUnauthorized !== false) {
|
||||
unauthorizedHandler?.();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw apiError(payload, response.status, response.statusText || "请求失败");
|
||||
}
|
||||
|
||||
if (isEnvelopeFailure(payload)) {
|
||||
throw apiError(payload, response.status, "请求失败");
|
||||
}
|
||||
|
||||
const data = unwrapPayload(payload);
|
||||
if (data && typeof data === "object" && "csrfToken" in data) {
|
||||
rememberExternalCsrfToken(data.csrfToken);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function buildUrl(path, query) {
|
||||
const normalizedPath = String(path || "").startsWith("/") ? path : `/${path}`;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Object.entries(query || {}).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return;
|
||||
}
|
||||
(Array.isArray(value) ? value : [value]).forEach((item) => params.append(key, String(item)));
|
||||
});
|
||||
|
||||
const search = params.toString();
|
||||
return `${EXTERNAL_API_BASE}${normalizedPath}${search ? `?${search}` : ""}`;
|
||||
}
|
||||
|
||||
function createBody(body, headers) {
|
||||
if (body === undefined || body === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (body instanceof FormData || typeof body === "string" || body instanceof Blob) {
|
||||
return body;
|
||||
}
|
||||
headers.set("Content-Type", "application/json");
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
async function readPayload(response) {
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return response.json();
|
||||
}
|
||||
const text = await response.text();
|
||||
return text ? { message: text } : null;
|
||||
}
|
||||
|
||||
function isEnvelopeFailure(payload) {
|
||||
if (!payload || typeof payload !== "object" || !("code" in payload)) {
|
||||
return false;
|
||||
}
|
||||
return ![0, 200, "0", "200", "OK", "SUCCESS"].includes(payload.code);
|
||||
}
|
||||
|
||||
function unwrapPayload(payload) {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return payload;
|
||||
}
|
||||
return "data" in payload ? payload.data : payload;
|
||||
}
|
||||
|
||||
function apiError(payload, status, fallback) {
|
||||
const source = payload && typeof payload === "object" ? payload : {};
|
||||
return new ExternalApiError(source.message || source.msg || fallback, {
|
||||
code: source.code,
|
||||
details: source.details || source.errors || null,
|
||||
status
|
||||
});
|
||||
}
|
||||
74
external-admin/src/api/client.test.js
Normal file
74
external-admin/src/api/client.test.js
Normal file
@ -0,0 +1,74 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
ExternalApiError,
|
||||
externalRequest,
|
||||
readExternalCsrfToken,
|
||||
rememberExternalCsrfToken,
|
||||
setExternalUnauthorizedHandler
|
||||
} from "./client.js";
|
||||
|
||||
describe("externalRequest", () => {
|
||||
beforeEach(() => {
|
||||
rememberExternalCsrfToken("");
|
||||
document.cookie = "hyapp_external_csrf=; Max-Age=0; Path=/";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setExternalUnauthorizedHandler(null);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("uses only the isolated cookie session and external API base", async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { items: [] } }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await externalRequest("/app/users", { query: { keyword: "163000", page: 1 } });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/v1/external/app/users?keyword=163000&page=1");
|
||||
expect(init.credentials).toBe("include");
|
||||
expect(init.headers.get("Authorization")).toBeNull();
|
||||
expect(init.headers.get("X-App-Code")).toBeNull();
|
||||
expect(init.headers.get("X-CSRF-Token")).toBeNull();
|
||||
});
|
||||
|
||||
test("adds CSRF to writes from the dedicated cookie", async () => {
|
||||
document.cookie = "hyapp_external_csrf=csrf%20token; Path=/";
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { ok: true } }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await externalRequest("/auth/change-password", { body: { currentPassword: "old", newPassword: "new" }, method: "POST" });
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(init.headers.get("X-CSRF-Token")).toBe("csrf token");
|
||||
expect(JSON.parse(init.body)).toEqual({ currentPassword: "old", newPassword: "new" });
|
||||
});
|
||||
|
||||
test("keeps csrfToken in memory when the API-scoped cookie is unreadable", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ code: 0, data: { csrfToken: "memory-csrf", user: { username: "operator" } } }))
|
||||
.mockResolvedValueOnce(jsonResponse({ code: 0, data: {} }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await externalRequest("/auth/me");
|
||||
await externalRequest("/auth/logout", { method: "POST" });
|
||||
|
||||
expect(fetchMock.mock.calls[1][1].headers.get("X-CSRF-Token")).toBe("memory-csrf");
|
||||
expect(readExternalCsrfToken("")).toBe("memory-csrf");
|
||||
});
|
||||
|
||||
test("invalidates the external session on protected 401", async () => {
|
||||
const handler = vi.fn();
|
||||
setExternalUnauthorizedHandler(handler);
|
||||
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ code: "UNAUTHORIZED", message: "请登录" }, 401)));
|
||||
|
||||
await expect(externalRequest("/admin/rooms")).rejects.toEqual(expect.objectContaining({ status: 401 }));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(new ExternalApiError("x")).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status });
|
||||
}
|
||||
59
external-admin/src/api/normalizers.js
Normal file
59
external-admin/src/api/normalizers.js
Normal file
@ -0,0 +1,59 @@
|
||||
export function normalizePage(data, mapItem = normalizeRecord) {
|
||||
const source = asRecord(data);
|
||||
const rawItems = Array.isArray(data)
|
||||
? data
|
||||
: arrayValue(source.items, source.agencies, source.list, source.records, source.content, source.rows);
|
||||
const page = positiveNumber(source.page ?? source.page_no ?? source.pageNo, 1);
|
||||
const pageSize = positiveNumber(source.pageSize ?? source.page_size ?? source.size, rawItems.length || 20);
|
||||
|
||||
return {
|
||||
items: rawItems.map((item) => mapItem(asRecord(item))),
|
||||
page,
|
||||
pageSize,
|
||||
total: nonNegativeNumber(source.total ?? source.total_count ?? source.totalCount, rawItems.length)
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeRecord(value) {
|
||||
return asRecord(value);
|
||||
}
|
||||
|
||||
export function asRecord(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
}
|
||||
|
||||
export function arrayValue(...values) {
|
||||
return values.find(Array.isArray) || [];
|
||||
}
|
||||
|
||||
export function textValue(...values) {
|
||||
const value = values.find((item) => item !== undefined && item !== null && item !== "");
|
||||
return value === undefined ? "" : String(value);
|
||||
}
|
||||
|
||||
export function numberValue(value, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function booleanValue(...values) {
|
||||
const value = values.find((item) => item !== undefined && item !== null);
|
||||
if (typeof value === "string") {
|
||||
return ["1", "true", "enabled", "active", "yes"].includes(value.toLowerCase());
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
export function entityId(item) {
|
||||
return textValue(item.id, item.userId, item.user_id, item.roomId, item.room_id, item.resourceId, item.resource_id);
|
||||
}
|
||||
|
||||
function positiveNumber(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function nonNegativeNumber(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
}
|
||||
73
external-admin/src/auth/ExternalAuthProvider.jsx
Normal file
73
external-admin/src/auth/ExternalAuthProvider.jsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { changeExternalPassword, getExternalSession, loginExternal, logoutExternal } from "../api/auth.js";
|
||||
import { ExternalApiError, setExternalUnauthorizedHandler } from "../api/client.js";
|
||||
|
||||
const ExternalAuthContext = createContext(null);
|
||||
|
||||
export function ExternalAuthProvider({ children }) {
|
||||
const [session, setSession] = useState(null);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const [initializationError, setInitializationError] = useState("");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setInitializationError("");
|
||||
try {
|
||||
const nextSession = await getExternalSession();
|
||||
setSession(nextSession);
|
||||
return nextSession;
|
||||
} catch (error) {
|
||||
setSession(null);
|
||||
if (!(error instanceof ExternalApiError && error.status === 401)) {
|
||||
setInitializationError(error.message || "会话校验失败");
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
setInitializing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
// Any protected request returning 401 invalidates the entire external session; it never mutates the main-admin session.
|
||||
return setExternalUnauthorizedHandler(() => setSession(null));
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (credentials) => {
|
||||
const nextSession = await loginExternal(credentials);
|
||||
setSession(nextSession);
|
||||
return nextSession;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await logoutExternal();
|
||||
} finally {
|
||||
setSession(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const changePassword = useCallback(async (payload) => {
|
||||
const responseSession = await changeExternalPassword(payload);
|
||||
const nextSession = responseSession?.appCode ? responseSession : await getExternalSession();
|
||||
setSession({ ...nextSession, passwordChangeRequired: false });
|
||||
return nextSession;
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ changePassword, initializationError, initializing, login, logout, refresh, session }),
|
||||
[changePassword, initializationError, initializing, login, logout, refresh, session]
|
||||
);
|
||||
|
||||
return <ExternalAuthContext.Provider value={value}>{children}</ExternalAuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useExternalAuth() {
|
||||
const context = useContext(ExternalAuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useExternalAuth must be used inside ExternalAuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
58
external-admin/src/auth/RouteGuards.jsx
Normal file
58
external-admin/src/auth/RouteGuards.jsx
Normal file
@ -0,0 +1,58 @@
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
import { hasAnyExternalCapability } from "../config/capabilities.js";
|
||||
import { useExternalAuth } from "./ExternalAuthProvider.jsx";
|
||||
|
||||
export function RequireExternalAuth() {
|
||||
const { initializationError, initializing, refresh, session } = useExternalAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<Box className="external-full-state" role="status">
|
||||
<CircularProgress size={28} />
|
||||
<Typography color="text.secondary">正在校验外管会话</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
if (initializationError) {
|
||||
return (
|
||||
<Box className="external-full-state">
|
||||
<Alert severity="error">{initializationError}</Alert>
|
||||
<Button onClick={refresh}>重新校验</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return <Navigate replace state={{ from: location }} to="/login" />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export function RequirePasswordChanged() {
|
||||
const { session } = useExternalAuth();
|
||||
return session?.passwordChangeRequired ? <Navigate replace to="/change-password" /> : <Outlet />;
|
||||
}
|
||||
|
||||
export function RequireExternalCapability({ anyOf }) {
|
||||
const { session } = useExternalAuth();
|
||||
|
||||
if (hasAnyExternalCapability(session, anyOf)) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="external-page external-full-state">
|
||||
<Alert severity="warning">当前外管账号没有访问此功能的权限。</Alert>
|
||||
<Button component="a" href="/external-admin/overview" variant="outlined">
|
||||
返回权限总览
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
110
external-admin/src/components/BannerCreateDialog.jsx
Normal file
110
external-admin/src/components/BannerCreateDialog.jsx
Normal file
@ -0,0 +1,110 @@
|
||||
import Button from "@mui/material/Button";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx";
|
||||
|
||||
const displayScopeOptions = [
|
||||
["home", "首页"],
|
||||
["room", "房间"],
|
||||
["recharge", "充值"],
|
||||
["me", "我的"]
|
||||
];
|
||||
|
||||
export function BannerCreateDialog({ loading, onClose, onSubmit, open }) {
|
||||
const [form, setForm] = useState(initialForm);
|
||||
useEffect(() => setForm(initialForm), [open]);
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault();
|
||||
onSubmit({
|
||||
...form,
|
||||
regionId: Number(form.regionId || 0),
|
||||
roomSmallImageUrl: form.displayScopes.includes("room") ? form.roomSmallImageUrl : "",
|
||||
sortOrder: Number(form.sortOrder || 0)
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
|
||||
<Stack component="form" onSubmit={submit}>
|
||||
<DialogTitle>创建 Banner</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack className="external-dialog-fields" spacing={2}>
|
||||
<ExternalImageUploadField disabled={loading} label="Banner 图片" value={form.coverUrl} onChange={(coverUrl) => setForm({ ...form, coverUrl })} />
|
||||
{form.displayScopes.includes("room") ? <ExternalImageUploadField disabled={loading} label="房间内小屏图" value={form.roomSmallImageUrl} onChange={(roomSmallImageUrl) => setForm({ ...form, roomSmallImageUrl })} /> : null}
|
||||
<TextField disabled={loading} label="平台" required select value={form.platform} onChange={(event) => setForm({ ...form, platform: event.target.value })}>
|
||||
<MenuItem value="android">Android</MenuItem>
|
||||
<MenuItem value="ios">iOS</MenuItem>
|
||||
</TextField>
|
||||
<TextField disabled={loading} label="描述" slotProps={{ htmlInput: { maxLength: 255 } }} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
|
||||
<AppJumpConfigField
|
||||
allowCustom
|
||||
appParamLabel="参数(APP 公共跳转 JSON)"
|
||||
disabled={loading}
|
||||
h5Label="参数(H5 链接)"
|
||||
paramValue={form.param}
|
||||
required
|
||||
typeLabel="跳转类型"
|
||||
typeValue={form.bannerType}
|
||||
onChange={({ param, type }) => setForm({ ...form, bannerType: type, param })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={loading}
|
||||
label="展示范围"
|
||||
required
|
||||
select
|
||||
slotProps={{ select: { multiple: true, renderValue: (selected) => selected.map((value) => displayScopeOptions.find(([key]) => key === value)?.[1] || value).join("、") } }}
|
||||
value={form.displayScopes}
|
||||
onChange={(event) => setForm({ ...form, displayScopes: typeof event.target.value === "string" ? event.target.value.split(",") : event.target.value })}
|
||||
>
|
||||
{displayScopeOptions.map(([value, label]) => <MenuItem key={value} value={value}><Checkbox checked={form.displayScopes.includes(value)} /><ListItemText primary={label} /></MenuItem>)}
|
||||
</TextField>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
|
||||
<TextField disabled={loading} label="区域 ID" slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.regionId} onChange={(event) => setForm({ ...form, regionId: event.target.value })} />
|
||||
<TextField disabled={loading} helperText="2-3 位字母,留空为全部" label="国家码" value={form.countryCode} onChange={(event) => setForm({ ...form, countryCode: event.target.value.toUpperCase() })} />
|
||||
<TextField disabled={loading} label="排序" slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.sortOrder} onChange={(event) => setForm({ ...form, sortOrder: event.target.value })} />
|
||||
</Stack>
|
||||
<TimeRangeFilter
|
||||
disabled={loading}
|
||||
label="投放时间"
|
||||
value={{ endMs: form.endsAtMs, startMs: form.startsAtMs }}
|
||||
onChange={({ endMs, startMs }) => setForm({ ...form, endsAtMs: endMs, startsAtMs: startMs })}
|
||||
/>
|
||||
<TextField disabled={loading} label="状态" select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
|
||||
<MenuItem value="active">启用</MenuItem>
|
||||
<MenuItem value="disabled">禁用</MenuItem>
|
||||
<MenuItem value="expired">过期</MenuItem>
|
||||
</TextField>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions><Button disabled={loading} onClick={onClose}>取消</Button><Button disabled={loading || !form.coverUrl} type="submit" variant="contained">创建</Button></DialogActions>
|
||||
</Stack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const initialForm = {
|
||||
bannerType: "h5",
|
||||
countryCode: "",
|
||||
coverUrl: "",
|
||||
description: "",
|
||||
displayScopes: ["home"],
|
||||
endsAtMs: 0,
|
||||
param: "",
|
||||
platform: "android",
|
||||
regionId: "",
|
||||
roomSmallImageUrl: "",
|
||||
sortOrder: "0",
|
||||
startsAtMs: 0,
|
||||
status: "active"
|
||||
};
|
||||
20
external-admin/src/components/BannerCreateDialog.test.jsx
Normal file
20
external-admin/src/components/BannerCreateDialog.test.jsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { BannerCreateDialog } from "./BannerCreateDialog.jsx";
|
||||
|
||||
describe("BannerCreateDialog", () => {
|
||||
test("uses the shared structured app jump field and unified time range picker", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BannerCreateDialog open onClose={vi.fn()} onSubmit={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("textbox", { name: "参数(H5 链接)" })).toBeInTheDocument();
|
||||
expect(screen.getByText("投放时间")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "跳转类型" }));
|
||||
await user.click(await screen.findByRole("option", { name: "APP" }));
|
||||
|
||||
expect(screen.getByRole("combobox", { name: "APP 目标" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "参数(APP 公共跳转 JSON)" })).toHaveValue(JSON.stringify({ type: "wallet" }));
|
||||
});
|
||||
});
|
||||
60
external-admin/src/components/GrantDialogs.jsx
Normal file
60
external-admin/src/components/GrantDialogs.jsx
Normal file
@ -0,0 +1,60 @@
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ResourceGrantDialog({ loading, onClose, onSubmit, open, resources }) {
|
||||
const [form, setForm] = useState(resourceGrantInitial);
|
||||
useEffect(() => setForm(resourceGrantInitial), [open]);
|
||||
return (
|
||||
<GrantDialog loading={loading} open={open} title="特权道具发放" onClose={onClose} onSubmit={() => onSubmit(form)}>
|
||||
<TargetNotice />
|
||||
<TextField disabled={loading} label="用户 ID / 短 ID(靓号)" required value={form.target} onChange={(event) => setForm({ ...form, target: event.target.value.trim() })} />
|
||||
<TextField disabled={loading} label="资源" required select value={form.resourceId} onChange={(event) => setForm({ ...form, resourceId: event.target.value })}>
|
||||
{resources.map((item) => <MenuItem key={item.id} value={item.id}>{item.name}({item.id})</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField disabled={loading} label="数量" required slotProps={{ htmlInput: { min: 1 } }} type="number" value={form.quantity} onChange={(event) => setForm({ ...form, quantity: event.target.value })} />
|
||||
<TextField disabled={loading} helperText="填 0 表示永久有效" label="有效天数" required slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.durationDays} onChange={(event) => setForm({ ...form, durationDays: event.target.value })} />
|
||||
<TextField disabled={loading} label="发放原因" minRows={2} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
|
||||
</GrantDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) {
|
||||
const [form, setForm] = useState(prettyGrantInitial);
|
||||
useEffect(() => setForm(prettyGrantInitial), [open]);
|
||||
return (
|
||||
<GrantDialog loading={loading} open={open} title="靓号下发" onClose={onClose} onSubmit={() => onSubmit(form)}>
|
||||
<TargetNotice />
|
||||
<TextField disabled={loading} label="用户 ID / 当前短 ID" required value={form.target} onChange={(event) => setForm({ ...form, target: event.target.value.trim() })} />
|
||||
<TextField disabled={loading} label="下发靓号" required slotProps={{ htmlInput: { inputMode: "numeric", pattern: "[0-9]*" } }} value={form.prettyId} onChange={(event) => setForm({ ...form, prettyId: event.target.value.trim() })} />
|
||||
<TextField disabled={loading} helperText="填 0 表示永久有效" label="有效天数" required slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.durationDays} onChange={(event) => setForm({ ...form, durationDays: event.target.value })} />
|
||||
<TextField disabled={loading} label="下发原因" minRows={2} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
|
||||
</GrantDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetNotice() {
|
||||
return <Alert severity="info">提交前会校验当前 App 内的用户,并使用服务端返回的用户 ID 执行发放。</Alert>;
|
||||
}
|
||||
|
||||
function GrantDialog({ children, loading, onClose, onSubmit, open, title }) {
|
||||
return (
|
||||
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
|
||||
<Stack component="form" onSubmit={(event) => { event.preventDefault(); onSubmit(); }}>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent><Stack className="external-dialog-fields" spacing={2}>{children}</Stack></DialogContent>
|
||||
<DialogActions><Button disabled={loading} onClick={onClose}>取消</Button><Button disabled={loading} type="submit" variant="contained">确认发放</Button></DialogActions>
|
||||
</Stack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const resourceGrantInitial = { durationDays: "30", quantity: "1", reason: "", resourceId: "", target: "" };
|
||||
const prettyGrantInitial = { durationDays: "0", prettyId: "", reason: "", target: "" };
|
||||
30
external-admin/src/components/RoomEditDialog.jsx
Normal file
30
external-admin/src/components/RoomEditDialog.jsx
Normal file
@ -0,0 +1,30 @@
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx";
|
||||
|
||||
export function RoomEditDialog({ loading, onClose, onSubmit, open, room }) {
|
||||
const [form, setForm] = useState({ coverUrl: "", description: "", title: "", visibleRegionId: "" });
|
||||
useEffect(() => {
|
||||
setForm({ coverUrl: room?.coverUrl || "", description: room?.description || "", title: room?.title || "", visibleRegionId: room?.visibleRegionId || "" });
|
||||
}, [room]);
|
||||
return (
|
||||
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
|
||||
<Stack component="form" onSubmit={(event) => { event.preventDefault(); onSubmit(form); }}>
|
||||
<DialogTitle>编辑房间</DialogTitle>
|
||||
<DialogContent><Stack className="external-dialog-fields" spacing={2}>
|
||||
<ExternalImageUploadField disabled={loading} label="房间背景图" value={form.coverUrl} onChange={(coverUrl) => setForm({ ...form, coverUrl })} />
|
||||
<TextField disabled={loading} label="房间名称" required value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} />
|
||||
<TextField disabled={loading} label="房间介绍" minRows={3} multiline value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
|
||||
<TextField disabled={loading} label="可见区域 ID" value={form.visibleRegionId} onChange={(event) => setForm({ ...form, visibleRegionId: event.target.value.trim() })} />
|
||||
</Stack></DialogContent>
|
||||
<DialogActions><Button disabled={loading} onClick={onClose}>取消</Button><Button disabled={loading} type="submit" variant="contained">保存</Button></DialogActions>
|
||||
</Stack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
69
external-admin/src/components/UserDialogs.jsx
Normal file
69
external-admin/src/components/UserDialogs.jsx
Normal file
@ -0,0 +1,69 @@
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx";
|
||||
|
||||
export function UserEditDialog({ loading, onClose, onSubmit, open, user }) {
|
||||
const [form, setForm] = useState(emptyEditForm);
|
||||
useEffect(() => {
|
||||
setForm({ avatar: user?.avatar || "", country: user?.country || "", gender: user?.gender || "", username: user?.username || "" });
|
||||
}, [user]);
|
||||
return (
|
||||
<BaseDialog loading={loading} open={open} title="编辑用户" onClose={onClose} onSubmit={() => onSubmit(form)}>
|
||||
<ExternalImageUploadField disabled={loading} label="头像" value={form.avatar} onChange={(avatar) => setForm({ ...form, avatar })} />
|
||||
<TextField disabled={loading} label="用户名称" required value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} />
|
||||
<TextField disabled={loading} label="性别" select value={form.gender} onChange={(event) => setForm({ ...form, gender: event.target.value })}>
|
||||
<MenuItem value="">未设置</MenuItem><MenuItem value="male">男</MenuItem><MenuItem value="female">女</MenuItem><MenuItem value="unknown">未知</MenuItem>
|
||||
</TextField>
|
||||
<TextField disabled={loading} label="国家/地区码" value={form.country} onChange={(event) => setForm({ ...form, country: event.target.value.trim().toUpperCase() })} />
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserBanDialog({ loading, onClose, onSubmit, open, user }) {
|
||||
const [form, setForm] = useState({ days: "7", reason: "" });
|
||||
useEffect(() => setForm({ days: "7", reason: "" }), [user]);
|
||||
return (
|
||||
<BaseDialog loading={loading} open={open} title={`封禁用户 ${user?.prettyId || user?.username || ""}`} onClose={onClose} onSubmit={() => onSubmit(form)}>
|
||||
<TextField disabled={loading} helperText="填 0 表示永久封禁" label="封禁天数" required slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.days} onChange={(event) => setForm({ ...form, days: event.target.value })} />
|
||||
<TextField disabled={loading} label="封禁原因" minRows={3} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserLevelDialog({ loading, onClose, onSubmit, open, user }) {
|
||||
const [form, setForm] = useState({ days: "30", level: "", reason: "", track: "wealth" });
|
||||
useEffect(() => setForm({ days: "30", level: "", reason: "", track: "wealth" }), [user]);
|
||||
return (
|
||||
<BaseDialog loading={loading} open={open} title={`等级下发 ${user?.prettyId || user?.username || ""}`} onClose={onClose} onSubmit={() => onSubmit(form)}>
|
||||
<TextField disabled={loading} label="等级类型" select value={form.track} onChange={(event) => setForm({ ...form, track: event.target.value })}><MenuItem value="wealth">财富等级</MenuItem><MenuItem value="charm">魅力等级</MenuItem><MenuItem value="vip">VIP 等级</MenuItem></TextField>
|
||||
<TextField disabled={loading} label="等级" required slotProps={{ htmlInput: { min: 1 } }} type="number" value={form.level} onChange={(event) => setForm({ ...form, level: event.target.value })} />
|
||||
<TextField disabled={loading} helperText="有效期至少 1 天" label="有效天数" required slotProps={{ htmlInput: { min: 1 } }} type="number" value={form.days} onChange={(event) => setForm({ ...form, days: event.target.value })} />
|
||||
<TextField disabled={loading} label="下发原因" minRows={2} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function BaseDialog({ children, loading, onClose, onSubmit, open, title }) {
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
};
|
||||
return (
|
||||
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
|
||||
<Stack component="form" onSubmit={handleSubmit}>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent><Stack className="external-dialog-fields" spacing={2}>{children}</Stack></DialogContent>
|
||||
<DialogActions><Button disabled={loading} onClick={onClose}>取消</Button><Button disabled={loading} type="submit" variant="contained">确认</Button></DialogActions>
|
||||
</Stack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyEditForm = { avatar: "", country: "", gender: "", username: "" };
|
||||
31
external-admin/src/config/capabilities.js
Normal file
31
external-admin/src/config/capabilities.js
Normal file
@ -0,0 +1,31 @@
|
||||
export const EXTERNAL_CAPABILITIES = [
|
||||
{ code: "user:list", label: "用户列表", path: "/users" },
|
||||
{ code: "user:update", label: "用户信息编辑", path: "/users" },
|
||||
{ code: "user-ban:list", label: "账号封禁列表", path: "/bans" },
|
||||
{ code: "user:ban", label: "执行封禁", path: "/users" },
|
||||
{ code: "user:unban", label: "解除封禁", path: "/bans" },
|
||||
{ code: "host:list", label: "主播列表", path: "/organization/hosts" },
|
||||
{ code: "agency:list", label: "公会列表", path: "/organization/agencies" },
|
||||
{ code: "bd:list", label: "BD 列表", path: "/organization/bds" },
|
||||
{ code: "bd-manager:list", label: "BD Manager 列表", path: "/organization/bd-leaders" },
|
||||
{ code: "super-admin:list", label: "Super Admin 列表", path: "/organization/super-admins" },
|
||||
{ code: "room:list", label: "房间管理", path: "/rooms" },
|
||||
{ code: "room:update", label: "房间编辑", path: "/rooms" },
|
||||
{ code: "privilege:list", label: "用户特权道具列表", path: "/grants" },
|
||||
{ code: "privilege:grant", label: "特权道具发放", path: "/grants" },
|
||||
{ code: "banner:create", label: "Banner 创建", path: "/banners" },
|
||||
{ code: "pretty-id:grant", label: "靓号下发", path: "/grants" },
|
||||
{ code: "user-title:grant", label: "用户称号下发", path: "/grants" },
|
||||
{ code: "room-background:grant", label: "房间背景图下发", path: "/grants" },
|
||||
{ code: "user-level:grant", label: "财富/VIP等级下发", path: "/users" },
|
||||
{ code: "team:view", label: "我的团队", path: "/organization/team" }
|
||||
];
|
||||
|
||||
export function hasExternalCapability(session, code) {
|
||||
const capabilities = session?.capabilities || [];
|
||||
return capabilities.includes("*") || capabilities.includes("external-admin:*") || capabilities.includes(code);
|
||||
}
|
||||
|
||||
export function hasAnyExternalCapability(session, codes) {
|
||||
return codes.some((code) => hasExternalCapability(session, code));
|
||||
}
|
||||
43
external-admin/src/config/entry-routing.test.js
Normal file
43
external-admin/src/config/entry-routing.test.js
Normal file
@ -0,0 +1,43 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { redirectSubsystemEntry } from "../../../vite.config.js";
|
||||
|
||||
describe("external-admin Vite entry routing", () => {
|
||||
test("redirects the bare subsystem path and preserves the app query", () => {
|
||||
const req = { url: "/external-admin?app=fami" };
|
||||
const res = responseStub();
|
||||
const next = vi.fn();
|
||||
|
||||
redirectSubsystemEntry(req, res, next);
|
||||
|
||||
expect(res.statusCode).toBe(301);
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Location", "/external-admin/?app=fami");
|
||||
expect(res.end).toHaveBeenCalled();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rewrites a BrowserRouter deep link to the isolated HTML document", () => {
|
||||
const req = { url: "/external-admin/organization/hosts?from=test" };
|
||||
const res = responseStub();
|
||||
const next = vi.fn();
|
||||
|
||||
redirectSubsystemEntry(req, res, next);
|
||||
|
||||
expect(req.url).toBe("/external-admin/index.html?from=test");
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(res.end).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not rewrite source module requests", () => {
|
||||
const req = { url: "/external-admin/src/main.jsx" };
|
||||
const next = vi.fn();
|
||||
|
||||
redirectSubsystemEntry(req, responseStub(), next);
|
||||
|
||||
expect(req.url).toBe("/external-admin/src/main.jsx");
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
function responseStub() {
|
||||
return { end: vi.fn(), setHeader: vi.fn(), statusCode: 200 };
|
||||
}
|
||||
128
external-admin/src/layout/ExternalAdminLayout.jsx
Normal file
128
external-admin/src/layout/ExternalAdminLayout.jsx
Normal file
@ -0,0 +1,128 @@
|
||||
import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined";
|
||||
import BadgeOutlined from "@mui/icons-material/BadgeOutlined";
|
||||
import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
|
||||
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
||||
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
||||
import LogoutOutlined from "@mui/icons-material/LogoutOutlined";
|
||||
import MenuOutlined from "@mui/icons-material/MenuOutlined";
|
||||
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
|
||||
import PeopleOutlineOutlined from "@mui/icons-material/PeopleOutlineOutlined";
|
||||
import PersonOffOutlined from "@mui/icons-material/PersonOffOutlined";
|
||||
import RedeemOutlined from "@mui/icons-material/RedeemOutlined";
|
||||
import SecurityOutlined from "@mui/icons-material/SecurityOutlined";
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import List from "@mui/material/List";
|
||||
import ListItemButton from "@mui/material/ListItemButton";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useMemo, useState } from "react";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
|
||||
import { hasAnyExternalCapability } from "../config/capabilities.js";
|
||||
|
||||
const drawerWidth = 248;
|
||||
const navigation = [
|
||||
{ icon: DashboardOutlined, label: "权限总览", path: "/overview" },
|
||||
{ capabilities: ["user:list", "user:update", "user:ban", "user-level:grant"], icon: PeopleOutlineOutlined, label: "用户管理", path: "/users" },
|
||||
{ capabilities: ["user-ban:list", "user:unban"], icon: PersonOffOutlined, label: "封禁管理", path: "/bans" },
|
||||
{ capabilities: ["host:list"], icon: BadgeOutlined, label: "主播列表", path: "/organization/hosts" },
|
||||
{ capabilities: ["agency:list"], icon: ApartmentOutlined, label: "公会列表", path: "/organization/agencies" },
|
||||
{ capabilities: ["bd:list"], icon: GroupsOutlined, label: "BD 列表", path: "/organization/bds" },
|
||||
{ capabilities: ["bd-manager:list"], icon: SecurityOutlined, label: "BD Manager", path: "/organization/bd-leaders" },
|
||||
{ capabilities: ["super-admin:list"], icon: SecurityOutlined, label: "Super Admin", path: "/organization/super-admins" },
|
||||
{ capabilities: ["room:list", "room:update"], icon: MeetingRoomOutlined, label: "房间管理", path: "/rooms" },
|
||||
{ capabilities: ["privilege:list", "privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"], icon: RedeemOutlined, label: "资源与靓号", path: "/grants" },
|
||||
{ capabilities: ["banner:create"], icon: ImageOutlined, label: "Banner 管理", path: "/banners" },
|
||||
{ capabilities: ["team:view"], icon: GroupsOutlined, label: "我的团队", path: "/organization/team" }
|
||||
];
|
||||
|
||||
export function ExternalAdminLayout() {
|
||||
const theme = useTheme();
|
||||
const mobile = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const { logout, session } = useExternalAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const visibleNavigation = useMemo(
|
||||
() => navigation.filter((item) => !item.capabilities || hasAnyExternalCapability(session, item.capabilities)),
|
||||
[session]
|
||||
);
|
||||
const current = [...visibleNavigation].reverse().find((item) => location.pathname.startsWith(item.path));
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate("/login", { replace: true });
|
||||
};
|
||||
|
||||
const drawer = (
|
||||
<Box className="external-sidebar" sx={{ width: drawerWidth }}>
|
||||
<Box className="external-logo">
|
||||
<Box className="external-logo-mark">HY</Box>
|
||||
<Box>
|
||||
<Typography fontWeight={750}>HYApp 外管</Typography>
|
||||
<Typography color="text.secondary" variant="caption">{session?.appName || session?.appCode}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider />
|
||||
<List className="external-nav">
|
||||
{visibleNavigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<ListItemButton
|
||||
component={NavLink}
|
||||
key={item.path}
|
||||
selected={location.pathname === item.path || location.pathname.startsWith(`${item.path}/`)}
|
||||
to={item.path}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
>
|
||||
<ListItemIcon><Icon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className="external-shell">
|
||||
<AppBar className="external-header" color="inherit" elevation={0} position="fixed" sx={{ ml: { md: `${drawerWidth}px` }, width: { md: `calc(100% - ${drawerWidth}px)` } }}>
|
||||
<Toolbar>
|
||||
{mobile ? <IconButton aria-label="展开菜单" onClick={() => setDrawerOpen(true)}><MenuOutlined /></IconButton> : null}
|
||||
<Typography component="h2" fontWeight={700} noWrap>{current?.label || "外管后台"}</Typography>
|
||||
<Box flex={1} />
|
||||
<Stack alignItems="center" direction="row" spacing={1}>
|
||||
<Avatar className="external-user-avatar">{String(session?.account || "管").slice(0, 1).toUpperCase()}</Avatar>
|
||||
{!mobile ? (
|
||||
<Box>
|
||||
<Typography fontWeight={650} lineHeight={1.2}>{session?.account}</Typography>
|
||||
<Typography color="text.secondary" variant="caption">{session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""}</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
<Tooltip title="退出登录">
|
||||
<Button aria-label="退出登录" color="inherit" onClick={handleLogout} startIcon={<LogoutOutlined />}>{mobile ? "" : "退出"}</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer open={drawerOpen} variant="temporary" onClose={() => setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer}</Drawer>
|
||||
<Drawer open variant="permanent" sx={{ display: { xs: "none", md: "block" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer}</Drawer>
|
||||
<Box className="external-main" component="main" sx={{ ml: { md: `${drawerWidth}px` } }}>
|
||||
<Toolbar />
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
27
external-admin/src/main.jsx
Normal file
27
external-admin/src/main.jsx
Normal file
@ -0,0 +1,27 @@
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { theme } from "@/theme.js";
|
||||
import { ExternalAdminApp } from "./ExternalAdminApp.jsx";
|
||||
import { ExternalAuthProvider } from "./auth/ExternalAuthProvider.jsx";
|
||||
import "@/styles/tokens.css";
|
||||
import "@/styles/shared-ui.css";
|
||||
import "./styles/index.css";
|
||||
|
||||
createRoot(document.getElementById("external-admin-root")).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ToastProvider>
|
||||
<CssBaseline />
|
||||
<BrowserRouter basename="/external-admin">
|
||||
<ExternalAuthProvider>
|
||||
<ExternalAdminApp />
|
||||
</ExternalAuthProvider>
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
49
external-admin/src/pages/BannersPage.jsx
Normal file
49
external-admin/src/pages/BannersPage.jsx
Normal file
@ -0,0 +1,49 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { createBanner, listBanners } from "../api/business.js";
|
||||
import { BannerCreateDialog } from "../components/BannerCreateDialog.jsx";
|
||||
import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx";
|
||||
import { usePagedResource } from "../shared/usePagedResource.js";
|
||||
|
||||
export default function BannersPage() {
|
||||
const { showToast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const load = useCallback(() => listBanners({ page, page_size: 20 }), [page]);
|
||||
const { data, error, loading, reload } = usePagedResource(load);
|
||||
const columns = useMemo(() => [
|
||||
{ key: "banner", label: "Banner", render: (item) => <Stack alignItems="center" direction="row" spacing={1}>{item.coverUrl ? <Box alt={item.description} className="external-banner-thumb" component="img" src={item.coverUrl} /> : null}<Box><Typography fontWeight={650}>{item.description || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id || "-"}</Typography></Box></Stack> },
|
||||
{ key: "type", label: "类型", render: (item) => item.bannerType || "-" },
|
||||
{ key: "platform", label: "平台", render: (item) => item.platform || "all" },
|
||||
{ key: "sort", label: "排序", render: (item) => item.sortOrder },
|
||||
{ key: "status", label: "状态", render: (item) => <StatusChip status={item.status} /> }
|
||||
], []);
|
||||
|
||||
const submit = async (form) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createBanner(form);
|
||||
showToast("Banner 已创建", "success");
|
||||
setOpen(false);
|
||||
reload();
|
||||
} catch (requestError) {
|
||||
showToast(requestError.message || "Banner 创建失败", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ExternalPage actions={<Button onClick={() => setOpen(true)} startIcon={<AddOutlined />} variant="contained">创建 Banner</Button>} title="Banner 管理">
|
||||
<ExternalPageState error={error} loading={loading} onRetry={reload} />
|
||||
{!loading && !error ? <><ResponsiveDataList columns={columns} items={data.items} rowKey={(item) => item.id} /><PagePagination {...data} onChange={setPage} /></> : null}
|
||||
<BannerCreateDialog loading={submitting} open={open} onClose={() => setOpen(false)} onSubmit={submit} />
|
||||
</ExternalPage>
|
||||
);
|
||||
}
|
||||
61
external-admin/src/pages/BansPage.jsx
Normal file
61
external-admin/src/pages/BansPage.jsx
Normal file
@ -0,0 +1,61 @@
|
||||
import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { listBannedUsers, unbanUser } from "../api/business.js";
|
||||
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
|
||||
import { hasExternalCapability } from "../config/capabilities.js";
|
||||
import { ConfirmDialog } from "../shared/ConfirmDialog.jsx";
|
||||
import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx";
|
||||
import { usePagedResource } from "../shared/usePagedResource.js";
|
||||
|
||||
export default function BansPage() {
|
||||
const { session } = useExternalAuth();
|
||||
const { showToast } = useToast();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [query, setQuery] = useState({ keyword: "", page: 1 });
|
||||
const [target, setTarget] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const load = useCallback(() => listBannedUsers({ keyword: query.keyword, page: query.page, page_size: 20 }), [query]);
|
||||
const { data, error, loading, reload } = usePagedResource(load);
|
||||
const canUnban = hasExternalCapability(session, "user:unban");
|
||||
|
||||
const handleUnban = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await unbanUser(target.id, { reason: "external-admin manual unban" });
|
||||
showToast("用户已解除封禁", "success");
|
||||
setTarget(null);
|
||||
reload();
|
||||
} catch (requestError) {
|
||||
showToast(requestError.message || "解除封禁失败", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ key: "user", label: "用户", render: (user) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={user.avatar}>{user.username.slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{user.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {user.id}</Typography></Box></Stack> },
|
||||
{ key: "prettyId", label: "短 ID(靓号)", render: (user) => user.prettyId || "-" },
|
||||
{ key: "reason", label: "封禁原因", render: (user) => user.reason || "-" },
|
||||
{ key: "status", label: "状态", render: () => <StatusChip status="banned" /> },
|
||||
{ key: "actions", label: "操作", render: (user) => canUnban ? <Button onClick={() => setTarget(user)} startIcon={<LockOpenOutlined />}>解除封禁</Button> : "-" }
|
||||
], [canUnban]);
|
||||
|
||||
return (
|
||||
<ExternalPage title="账号封禁列表">
|
||||
<Stack className="external-filter-bar" component="form" direction={{ xs: "column", sm: "row" }} spacing={1.5} onSubmit={(event) => { event.preventDefault(); setQuery({ keyword, page: 1 }); }}>
|
||||
<TextField label="用户 ID / 短 ID / 名称" value={keyword} onChange={(event) => setKeyword(event.target.value)} />
|
||||
<Button type="submit" variant="contained">查询</Button>
|
||||
</Stack>
|
||||
<ExternalPageState error={error} loading={loading} onRetry={reload} />
|
||||
{!loading && !error ? <><ResponsiveDataList columns={columns} items={data.items} rowKey={(item) => item.id} /><PagePagination {...data} onChange={(page) => setQuery({ ...query, page })} /></> : null}
|
||||
<ConfirmDialog confirmColor="primary" content={`确认解除用户 ${target?.prettyId || target?.username || ""} 的封禁?`} loading={submitting} open={Boolean(target)} title="解除封禁" onClose={() => setTarget(null)} onConfirm={handleUnban} />
|
||||
</ExternalPage>
|
||||
);
|
||||
}
|
||||
69
external-admin/src/pages/ChangePasswordPage.jsx
Normal file
69
external-admin/src/pages/ChangePasswordPage.jsx
Normal file
@ -0,0 +1,69 @@
|
||||
import KeyOutlined from "@mui/icons-material/KeyOutlined";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Card from "@mui/material/Card";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const { changePassword, logout, session } = useExternalAuth();
|
||||
const [form, setForm] = useState({ confirmPassword: "", newPassword: "", oldPassword: "" });
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!form.newPassword.trim()) {
|
||||
setError("新密码不能为空或全为空白字符");
|
||||
return;
|
||||
}
|
||||
if (form.newPassword.length < 8) {
|
||||
setError("新密码至少 8 个字符");
|
||||
return;
|
||||
}
|
||||
if (form.newPassword !== form.confirmPassword) {
|
||||
setError("两次输入的新密码不一致");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
await changePassword({ newPassword: form.newPassword, oldPassword: form.oldPassword });
|
||||
navigate("/overview", { replace: true });
|
||||
} catch (requestError) {
|
||||
setError(requestError.message || "密码修改失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = async () => {
|
||||
await logout();
|
||||
navigate("/login", { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="external-login-page">
|
||||
<Card className="external-login-card" elevation={0}>
|
||||
<CardContent>
|
||||
<Stack alignItems="center" spacing={1.5}><Avatar className="external-login-avatar"><KeyOutlined /></Avatar><Typography component="h1" variant="h5">修改外管密码</Typography><Typography color="text.secondary">{session?.passwordChangeRequired ? "首次登录必须修改密码" : session?.account}</Typography></Stack>
|
||||
<Stack component="form" spacing={2} onSubmit={submit}>
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
<TextField autoComplete="current-password" disabled={submitting} label="当前密码" required type="password" value={form.oldPassword} onChange={(event) => setForm({ ...form, oldPassword: event.target.value })} />
|
||||
<TextField autoComplete="new-password" disabled={submitting} helperText="至少 8 个字符" label="新密码" required type="password" value={form.newPassword} onChange={(event) => setForm({ ...form, newPassword: event.target.value })} />
|
||||
<TextField autoComplete="new-password" disabled={submitting} label="确认新密码" required type="password" value={form.confirmPassword} onChange={(event) => setForm({ ...form, confirmPassword: event.target.value })} />
|
||||
<Stack direction="row" spacing={1}><Button disabled={submitting} fullWidth onClick={cancel} variant="outlined">退出登录</Button><Button disabled={submitting} fullWidth type="submit" variant="contained">保存密码</Button></Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
47
external-admin/src/pages/ChangePasswordPage.test.jsx
Normal file
47
external-admin/src/pages/ChangePasswordPage.test.jsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import ChangePasswordPage from "./ChangePasswordPage.jsx";
|
||||
|
||||
const auth = vi.hoisted(() => ({
|
||||
changePassword: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
session: { account: "fami-manager", passwordChangeRequired: true }
|
||||
}));
|
||||
|
||||
vi.mock("../auth/ExternalAuthProvider.jsx", () => ({ useExternalAuth: () => auth }));
|
||||
|
||||
describe("ChangePasswordPage", () => {
|
||||
beforeEach(() => {
|
||||
auth.changePassword.mockReset().mockResolvedValue({});
|
||||
auth.logout.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test("blocks a whitespace-only new password", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<MemoryRouter><ChangePasswordPage /></MemoryRouter>);
|
||||
const [newPassword, confirmPassword] = container.querySelectorAll('input[autocomplete="new-password"]');
|
||||
|
||||
fireEvent.change(container.querySelector('input[autocomplete="current-password"]'), { target: { value: "old-password" } });
|
||||
fireEvent.change(newPassword, { target: { value: " " } });
|
||||
fireEvent.change(confirmPassword, { target: { value: " " } });
|
||||
await user.click(screen.getByRole("button", { name: "保存密码" }));
|
||||
|
||||
expect(screen.getByText("新密码不能为空或全为空白字符")).toBeInTheDocument();
|
||||
expect(auth.changePassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("preserves password bytes instead of trimming before submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<MemoryRouter><ChangePasswordPage /></MemoryRouter>);
|
||||
const [newPassword, confirmPassword] = container.querySelectorAll('input[autocomplete="new-password"]');
|
||||
|
||||
fireEvent.change(container.querySelector('input[autocomplete="current-password"]'), { target: { value: " current-password " } });
|
||||
fireEvent.change(newPassword, { target: { value: " pass word " } });
|
||||
fireEvent.change(confirmPassword, { target: { value: " pass word " } });
|
||||
await user.click(screen.getByRole("button", { name: "保存密码" }));
|
||||
|
||||
expect(auth.changePassword).toHaveBeenCalledWith({ newPassword: " pass word ", oldPassword: " current-password " });
|
||||
});
|
||||
});
|
||||
106
external-admin/src/pages/GrantsPage.jsx
Normal file
106
external-admin/src/pages/GrantsPage.jsx
Normal file
@ -0,0 +1,106 @@
|
||||
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||
import NumbersOutlined from "@mui/icons-material/NumbersOutlined";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { grantPrettyId, grantResource, listResourceGrants, listResources, lookupGrantTarget } from "../api/business.js";
|
||||
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
|
||||
import { PrettyIdGrantDialog, ResourceGrantDialog } from "../components/GrantDialogs.jsx";
|
||||
import { hasAnyExternalCapability, hasExternalCapability } from "../config/capabilities.js";
|
||||
import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx";
|
||||
import { usePagedResource } from "../shared/usePagedResource.js";
|
||||
|
||||
export default function GrantsPage() {
|
||||
const { session } = useExternalAuth();
|
||||
const { showToast } = useToast();
|
||||
const [dialog, setDialog] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const canList = hasExternalCapability(session, "privilege:list");
|
||||
const canGrant = hasAnyExternalCapability(session, ["privilege:grant", "user-title:grant", "room-background:grant"]);
|
||||
const canGrantPrettyId = hasExternalCapability(session, "pretty-id:grant");
|
||||
const loadResources = useCallback(() => canGrant ? listResources({ page: 1, page_size: 200, status: "active" }) : Promise.resolve({ items: [], page: 1, pageSize: 200, total: 0 }), [canGrant]);
|
||||
const loadGrants = useCallback(() => canList ? listResourceGrants({ page, page_size: 20 }) : Promise.resolve({ items: [], page: 1, pageSize: 20, total: 0 }), [canList, page]);
|
||||
const resourcesState = usePagedResource(loadResources);
|
||||
const grantsState = usePagedResource(loadGrants);
|
||||
|
||||
const execute = async (request, successMessage) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await request();
|
||||
showToast(successMessage, "success");
|
||||
setDialog("");
|
||||
grantsState.reload();
|
||||
} catch (requestError) {
|
||||
showToast(requestError.message || "发放失败", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveTarget = async (input) => {
|
||||
const target = await lookupGrantTarget(input);
|
||||
if (!target.id) {
|
||||
throw new Error("未找到当前 App 内的目标用户");
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
const submitResource = (form) => execute(async () => {
|
||||
const target = await resolveTarget(form.target);
|
||||
await grantResource({
|
||||
commandId: createCommandId("external-resource-grant"),
|
||||
durationMs: Number(form.durationDays) * 86400000,
|
||||
quantity: Number(form.quantity),
|
||||
reason: form.reason.trim(),
|
||||
resourceId: Number(form.resourceId),
|
||||
targetUserId: target.id
|
||||
});
|
||||
}, "特权道具已发放");
|
||||
|
||||
const submitPrettyId = (form) => execute(async () => {
|
||||
const target = await resolveTarget(form.target);
|
||||
await grantPrettyId({
|
||||
display_user_id: form.prettyId,
|
||||
duration_ms: Number(form.durationDays) * 86400000,
|
||||
reason: form.reason.trim(),
|
||||
target_user_id: target.id
|
||||
});
|
||||
}, "靓号已下发");
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ key: "target", label: "目标用户", render: (item) => String(item.targetUserId || item.target_user_id || item.userId || item.user_id || "-") },
|
||||
{ key: "resource", label: "资源", render: (item) => String(item.resourceName || item.resource_name || item.resourceId || item.resource_id || item.subject || "-") },
|
||||
{ key: "quantity", label: "数量", render: (item) => String(item.quantity ?? item.amount ?? "-") },
|
||||
{ key: "status", label: "状态", render: (item) => <StatusChip status={item.status} /> },
|
||||
{ key: "reason", label: "原因", render: (item) => String(item.reason || "-") }
|
||||
], []);
|
||||
|
||||
return (
|
||||
<ExternalPage
|
||||
actions={<>{canGrant ? <Button disabled={resourcesState.loading} onClick={() => setDialog("resource")} startIcon={<CardGiftcardOutlined />} variant="contained">发放道具</Button> : null}{canGrantPrettyId ? <Button onClick={() => setDialog("pretty")} startIcon={<NumbersOutlined />} variant="outlined">下发靓号</Button> : null}</>}
|
||||
title="资源与靓号"
|
||||
>
|
||||
{canGrant ? (
|
||||
<Box className="external-form-panel">
|
||||
<Typography fontWeight={700} gutterBottom>可发放资源</Typography>
|
||||
<Stack direction="row" flexWrap="wrap" gap={1}>
|
||||
{resourcesState.data.items.slice(0, 12).map((resource) => <Stack alignItems="center" direction="row" key={resource.id} spacing={0.75}><Avatar src={resource.coverUrl} sx={{ height: 28, width: 28 }} variant="rounded">资</Avatar><Typography variant="body2">{resource.name}</Typography></Stack>)}
|
||||
{!resourcesState.loading && !resourcesState.data.items.length ? <Typography color="text.secondary">当前无数据</Typography> : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
{canList ? <><ExternalPageState error={grantsState.error} loading={grantsState.loading} onRetry={grantsState.reload} />{!grantsState.loading && !grantsState.error ? <><ResponsiveDataList columns={columns} items={grantsState.data.items} rowKey={(item, index) => item.grantId || item.grant_id || index} /><PagePagination {...grantsState.data} onChange={setPage} /></> : null}</> : null}
|
||||
<ResourceGrantDialog loading={submitting} open={dialog === "resource"} resources={resourcesState.data.items} onClose={() => setDialog("")} onSubmit={submitResource} />
|
||||
<PrettyIdGrantDialog loading={submitting} open={dialog === "pretty"} onClose={() => setDialog("")} onSubmit={submitPrettyId} />
|
||||
</ExternalPage>
|
||||
);
|
||||
}
|
||||
|
||||
function createCommandId(prefix) {
|
||||
return `${prefix}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`;
|
||||
}
|
||||
123
external-admin/src/pages/LoginPage.jsx
Normal file
123
external-admin/src/pages/LoginPage.jsx
Normal file
@ -0,0 +1,123 @@
|
||||
import LockOutlined from "@mui/icons-material/LockOutlined";
|
||||
import VisibilityOffOutlined from "@mui/icons-material/VisibilityOffOutlined";
|
||||
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Card from "@mui/material/Card";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import FormControl from "@mui/material/FormControl";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import InputLabel from "@mui/material/InputLabel";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Select from "@mui/material/Select";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { listExternalApps } from "../api/auth.js";
|
||||
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { initializing, login, session } = useExternalAuth();
|
||||
const [apps, setApps] = useState([]);
|
||||
const [appsError, setAppsError] = useState("");
|
||||
const [appsLoading, setAppsLoading] = useState(true);
|
||||
const [form, setForm] = useState({ account: "", appCode: "", password: "" });
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const loadApps = useCallback(async () => {
|
||||
setAppsLoading(true);
|
||||
setAppsError("");
|
||||
try {
|
||||
const items = await listExternalApps();
|
||||
setApps(items);
|
||||
const requestedApp = searchParams.get("app") || "";
|
||||
const preferredApp = items.find((item) => item.appCode === requestedApp)?.appCode || items[0]?.appCode || requestedApp;
|
||||
setForm((current) => ({ ...current, appCode: current.appCode || preferredApp }));
|
||||
} catch (requestError) {
|
||||
setAppsError(requestError.message || "App 列表加载失败");
|
||||
} finally {
|
||||
setAppsLoading(false);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
loadApps();
|
||||
}, [loadApps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initializing && session) {
|
||||
const destination = session.passwordChangeRequired ? "/change-password" : location.state?.from?.pathname || "/overview";
|
||||
navigate(destination, { replace: true });
|
||||
}
|
||||
}, [initializing, location.state, navigate, session]);
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!form.appCode || !form.account.trim() || !form.password) {
|
||||
setError("请选择 App 并填写账号、密码");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const nextSession = await login(form);
|
||||
navigate(nextSession.passwordChangeRequired ? "/change-password" : "/overview", { replace: true });
|
||||
} catch (requestError) {
|
||||
setError(requestError.message || "登录失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="external-login-page">
|
||||
<Card className="external-login-card" elevation={0}>
|
||||
<CardContent>
|
||||
<Stack alignItems="center" spacing={1.5}>
|
||||
<Avatar className="external-login-avatar"><LockOutlined /></Avatar>
|
||||
<Typography component="h1" variant="h5">外管后台登录</Typography>
|
||||
</Stack>
|
||||
<Stack component="form" spacing={2.25} onSubmit={handleSubmit}>
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
{appsError ? <Alert action={<Button color="inherit" onClick={loadApps} size="small">重试</Button>} severity="error">{appsError}</Alert> : null}
|
||||
{apps.length || appsLoading ? (
|
||||
<FormControl disabled={appsLoading || submitting} fullWidth required>
|
||||
<InputLabel id="external-login-app-label">App</InputLabel>
|
||||
<Select label="App" labelId="external-login-app-label" value={form.appCode} onChange={(event) => setForm({ ...form, appCode: event.target.value })}>
|
||||
{apps.map((app) => <MenuItem key={app.appCode} value={app.appCode}>{app.name}({app.appCode})</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : (
|
||||
<TextField disabled={submitting} label="App Code" required value={form.appCode} onChange={(event) => setForm({ ...form, appCode: event.target.value.trim() })} />
|
||||
)}
|
||||
<TextField autoComplete="username" disabled={submitting} label="外管账号" required value={form.account} onChange={(event) => setForm({ ...form, account: event.target.value })} />
|
||||
<TextField
|
||||
autoComplete="current-password"
|
||||
disabled={submitting}
|
||||
label="密码"
|
||||
required
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={form.password}
|
||||
onChange={(event) => setForm({ ...form, password: event.target.value })}
|
||||
slotProps={{ input: { endAdornment: <InputAdornment position="end"><IconButton aria-label={showPassword ? "隐藏密码" : "显示密码"} edge="end" onClick={() => setShowPassword((current) => !current)}>{showPassword ? <VisibilityOffOutlined /> : <VisibilityOutlined />}</IconButton></InputAdornment> } }}
|
||||
/>
|
||||
<Button disabled={appsLoading || submitting} size="large" type="submit" variant="contained">
|
||||
{submitting ? <CircularProgress color="inherit" size={20} /> : "登录"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
70
external-admin/src/pages/OrganizationPage.jsx
Normal file
70
external-admin/src/pages/OrganizationPage.jsx
Normal file
@ -0,0 +1,70 @@
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { listOrganizations } from "../api/business.js";
|
||||
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
|
||||
import { hasExternalCapability } from "../config/capabilities.js";
|
||||
import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx";
|
||||
import { usePagedResource } from "../shared/usePagedResource.js";
|
||||
|
||||
const organizationKinds = {
|
||||
agencies: { capability: "agency:list", title: "公会列表" },
|
||||
bds: { capability: "bd:list", title: "BD 列表" },
|
||||
"bd-leaders": { capability: "bd-manager:list", title: "BD Manager 列表" },
|
||||
hosts: { capability: "host:list", title: "主播列表" },
|
||||
managers: { capability: "bd-manager:list", title: "管理员列表" },
|
||||
"super-admins": { capability: "super-admin:list", title: "Super Admin 列表" },
|
||||
team: { capability: "team:view", title: "我的团队" }
|
||||
};
|
||||
|
||||
export default function OrganizationPage() {
|
||||
const { kind = "hosts" } = useParams();
|
||||
const config = organizationKinds[kind];
|
||||
const { session } = useExternalAuth();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [query, setQuery] = useState({ keyword: "", page: 1 });
|
||||
const allowed = Boolean(config && hasExternalCapability(session, config.capability));
|
||||
const load = useCallback(() => allowed ? listOrganizations(kind, { keyword: query.keyword, page: query.page, page_size: 20 }) : Promise.resolve({ items: [], page: 1, pageSize: 20, total: 0 }), [allowed, kind, query]);
|
||||
const { data, error, loading, reload } = usePagedResource(load);
|
||||
const columns = useMemo(() => kind === "team" ? [
|
||||
{ key: "agency", label: "公会", render: (item) => <Box><Typography fontWeight={650}>公会 {item.id || "-"}</Typography><Typography color="text.secondary" variant="caption">Owner {item.userId || "-"}</Typography></Box> },
|
||||
{ key: "parent", label: "上级 BD", render: (item) => item.parentBdUserId || "-" },
|
||||
{ key: "status", label: "状态", render: (item) => <StatusChip status={item.status} /> }
|
||||
] : [
|
||||
{ key: "identity", label: "成员", render: (item) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={item.avatar}>{(item.name || item.username || "-").slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{item.name || item.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / 用户 ${item.userId}` : ""}</Typography></Box></Stack> },
|
||||
{ key: "prettyId", label: "短 ID(靓号)", render: (item) => item.prettyId || "-" },
|
||||
{ key: "country", label: "国家/地区", render: (item) => item.country || "-" },
|
||||
{ key: "status", label: "状态", render: (item) => <StatusChip status={item.status} /> }
|
||||
], [kind]);
|
||||
|
||||
if (!config || !allowed) {
|
||||
return <ExternalPage title={config?.title || "组织管理"}><Alert severity="warning">当前外管账号没有访问该组织列表的权限。</Alert></ExternalPage>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ExternalPage title={config.title}>
|
||||
<Stack className="external-filter-bar" component="form" direction={{ xs: "column", sm: "row" }} spacing={1.5} onSubmit={(event) => { event.preventDefault(); setQuery({ keyword, page: 1 }); }}>
|
||||
<TextField label={kind === "team" ? "公会 ID / Owner 用户 ID / 上级 BD ID" : "用户 ID / 短 ID / 名称"} value={keyword} onChange={(event) => setKeyword(event.target.value)} />
|
||||
<Button type="submit" variant="contained">查询</Button>
|
||||
</Stack>
|
||||
<ExternalPageState error={error} loading={loading} onRetry={reload} />
|
||||
{kind === "team" && !loading && !error ? (
|
||||
<Box className="external-form-panel">
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={{ xs: 1, sm: 4 }}>
|
||||
<Typography>BD Leader:<strong>{data.totalBdLeaders || 0}</strong></Typography>
|
||||
<Typography>BD:<strong>{data.totalBds || 0}</strong></Typography>
|
||||
<Typography>公会:<strong>{data.total || data.items.length}</strong></Typography>
|
||||
</Stack>
|
||||
{data.truncated ? <Alert severity="warning" sx={{ mt: 2 }}>团队规模超过接口上限,当前结果不完整</Alert> : null}
|
||||
</Box>
|
||||
) : null}
|
||||
{!loading && !error ? <><ResponsiveDataList columns={columns} items={data.items} rowKey={(item, index) => item.id || index} /><PagePagination {...data} onChange={(page) => setQuery({ ...query, page })} /></> : null}
|
||||
</ExternalPage>
|
||||
);
|
||||
}
|
||||
50
external-admin/src/pages/OverviewPage.jsx
Normal file
50
external-admin/src/pages/OverviewPage.jsx
Normal file
@ -0,0 +1,50 @@
|
||||
import CheckCircleOutlineOutlined from "@mui/icons-material/CheckCircleOutlineOutlined";
|
||||
import LockOutlined from "@mui/icons-material/LockOutlined";
|
||||
import Box from "@mui/material/Box";
|
||||
import Card from "@mui/material/Card";
|
||||
import CardActionArea from "@mui/material/CardActionArea";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
|
||||
import { EXTERNAL_CAPABILITIES, hasExternalCapability } from "../config/capabilities.js";
|
||||
import { ExternalPage } from "../shared/PageComponents.jsx";
|
||||
|
||||
export default function OverviewPage() {
|
||||
const { session } = useExternalAuth();
|
||||
|
||||
return (
|
||||
<ExternalPage title="权限总览">
|
||||
<Card className="external-session-card" variant="outlined">
|
||||
<CardContent>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={{ xs: 1, sm: 4 }}>
|
||||
<Box><Typography color="text.secondary" variant="caption">当前 App</Typography><Typography fontWeight={700}>{session.appName || session.appCode}({session.appCode})</Typography></Box>
|
||||
<Box><Typography color="text.secondary" variant="caption">外管账号</Typography><Typography fontWeight={700}>{session.account}</Typography></Box>
|
||||
<Box><Typography color="text.secondary" variant="caption">已开通权限</Typography><Typography fontWeight={700}>{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length}</Typography></Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Box className="external-capability-grid">
|
||||
{EXTERNAL_CAPABILITIES.map((item) => {
|
||||
const enabled = hasExternalCapability(session, item.code);
|
||||
const content = (
|
||||
<CardContent>
|
||||
<Stack alignItems="flex-start" spacing={1.5}>
|
||||
{enabled ? <CheckCircleOutlineOutlined color="success" /> : <LockOutlined color="disabled" />}
|
||||
<Typography fontWeight={700}>{item.label}</Typography>
|
||||
<Chip color={enabled ? "success" : "default"} label={enabled ? "已开通" : "未开通"} size="small" variant="outlined" />
|
||||
</Stack>
|
||||
</CardContent>
|
||||
);
|
||||
return (
|
||||
<Card className={enabled ? "external-capability-card is-enabled" : "external-capability-card"} key={item.code} variant="outlined">
|
||||
{enabled ? <CardActionArea component={Link} to={item.path}>{content}</CardActionArea> : content}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</ExternalPage>
|
||||
);
|
||||
}
|
||||
31
external-admin/src/pages/OverviewPage.test.jsx
Normal file
31
external-admin/src/pages/OverviewPage.test.jsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { EXTERNAL_CAPABILITIES } from "../config/capabilities.js";
|
||||
import OverviewPage from "./OverviewPage.jsx";
|
||||
|
||||
const authState = vi.hoisted(() => ({
|
||||
session: {
|
||||
account: "fami-operator",
|
||||
appCode: "fami",
|
||||
appName: "Fami",
|
||||
capabilities: ["user:list"]
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("../auth/ExternalAuthProvider.jsx", () => ({
|
||||
useExternalAuth: () => authState
|
||||
}));
|
||||
|
||||
describe("OverviewPage", () => {
|
||||
test("renders all 20 market capability entries and only links enabled entries", () => {
|
||||
render(<MemoryRouter><OverviewPage /></MemoryRouter>);
|
||||
|
||||
expect(EXTERNAL_CAPABILITIES).toHaveLength(20);
|
||||
expect(screen.getAllByText("已开通")).toHaveLength(1);
|
||||
expect(screen.getAllByText("未开通")).toHaveLength(19);
|
||||
expect(screen.getByText("用户列表").closest("a")).toHaveAttribute("href", "/users");
|
||||
expect(screen.getByText("用户信息编辑").closest("a")).toBeNull();
|
||||
expect(screen.getByText("我的团队")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
63
external-admin/src/pages/RoomsPage.jsx
Normal file
63
external-admin/src/pages/RoomsPage.jsx
Normal file
@ -0,0 +1,63 @@
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { listRooms, updateRoom } from "../api/business.js";
|
||||
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
|
||||
import { RoomEditDialog } from "../components/RoomEditDialog.jsx";
|
||||
import { hasExternalCapability } from "../config/capabilities.js";
|
||||
import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx";
|
||||
import { usePagedResource } from "../shared/usePagedResource.js";
|
||||
|
||||
export default function RoomsPage() {
|
||||
const { session } = useExternalAuth();
|
||||
const { showToast } = useToast();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [query, setQuery] = useState({ keyword: "", page: 1 });
|
||||
const [room, setRoom] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const load = useCallback(() => listRooms({ keyword: query.keyword, page: query.page, page_size: 20 }), [query]);
|
||||
const { data, error, loading, reload } = usePagedResource(load);
|
||||
const canEdit = hasExternalCapability(session, "room:update");
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ key: "room", label: "房间", render: (item) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={item.coverUrl} variant="rounded">房</Avatar><Box><Typography fontWeight={650}>{item.title || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id}</Typography></Box></Stack> },
|
||||
{ key: "owner", label: "房主", render: (item) => item.ownerName || "-" },
|
||||
{ key: "region", label: "可见区域", render: (item) => item.visibleRegionId || "全部" },
|
||||
{ key: "status", label: "状态", render: (item) => <StatusChip status={item.status} /> },
|
||||
{ key: "actions", label: "操作", render: (item) => canEdit ? <Button onClick={() => setRoom(item)} startIcon={<EditOutlined />}>编辑</Button> : "-" }
|
||||
], [canEdit]);
|
||||
|
||||
const submit = async (form) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const regionText = String(form.visibleRegionId || "").trim();
|
||||
const visibleRegionId = regionText ? Number(regionText) : 0;
|
||||
if (!Number.isSafeInteger(visibleRegionId) || visibleRegionId < 0) {
|
||||
throw new Error("可见区域 ID 必须是安全的正整数");
|
||||
}
|
||||
await updateRoom(room.id, { ...form, visibleRegionId });
|
||||
showToast("房间信息已更新", "success");
|
||||
setRoom(null);
|
||||
reload();
|
||||
} catch (requestError) {
|
||||
showToast(requestError.message || "房间更新失败", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ExternalPage title="房间管理">
|
||||
<Stack className="external-filter-bar" component="form" direction={{ xs: "column", sm: "row" }} spacing={1.5} onSubmit={(event) => { event.preventDefault(); setQuery({ keyword, page: 1 }); }}><TextField label="房间 ID / 名称 / 房主" value={keyword} onChange={(event) => setKeyword(event.target.value)} /><Button type="submit" variant="contained">查询</Button></Stack>
|
||||
<ExternalPageState error={error} loading={loading} onRetry={reload} />
|
||||
{!loading && !error ? <><ResponsiveDataList columns={columns} items={data.items} rowKey={(item) => item.id} /><PagePagination {...data} onChange={(page) => setQuery({ ...query, page })} /></> : null}
|
||||
<RoomEditDialog loading={submitting} open={Boolean(room)} room={room} onClose={() => setRoom(null)} onSubmit={submit} />
|
||||
</ExternalPage>
|
||||
);
|
||||
}
|
||||
77
external-admin/src/pages/UsersPage.jsx
Normal file
77
external-admin/src/pages/UsersPage.jsx
Normal file
@ -0,0 +1,77 @@
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import GppBadOutlined from "@mui/icons-material/GppBadOutlined";
|
||||
import TrendingUpOutlined from "@mui/icons-material/TrendingUpOutlined";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { banUser, grantUserVip, listUsers, updateUser, updateUserLevels } from "../api/business.js";
|
||||
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
|
||||
import { UserBanDialog, UserEditDialog, UserLevelDialog } from "../components/UserDialogs.jsx";
|
||||
import { hasExternalCapability } from "../config/capabilities.js";
|
||||
import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx";
|
||||
import { usePagedResource } from "../shared/usePagedResource.js";
|
||||
|
||||
export default function UsersPage() {
|
||||
const { session } = useExternalAuth();
|
||||
const { showToast } = useToast();
|
||||
const [filters, setFilters] = useState({ keyword: "", status: "" });
|
||||
const [query, setQuery] = useState({ keyword: "", page: 1, status: "" });
|
||||
const [dialog, setDialog] = useState({ type: "", user: null });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const load = useCallback(() => listUsers({ keyword: query.keyword, page: query.page, page_size: 20, status: query.status }), [query]);
|
||||
const { data, error, loading, reload } = usePagedResource(load);
|
||||
const canEdit = hasExternalCapability(session, "user:update");
|
||||
const canBan = hasExternalCapability(session, "user:ban");
|
||||
const canGrantLevel = hasExternalCapability(session, "user-level:grant");
|
||||
|
||||
const closeDialog = () => setDialog({ type: "", user: null });
|
||||
const execute = async (request, successMessage) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await request();
|
||||
showToast(successMessage, "success");
|
||||
closeDialog();
|
||||
reload();
|
||||
} catch (requestError) {
|
||||
showToast(requestError.message || "用户操作失败", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ key: "user", label: "用户", render: (user) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={user.avatar}>{user.username.slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{user.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {user.id}</Typography></Box></Stack> },
|
||||
{ key: "prettyId", label: "短 ID(靓号)", render: (user) => user.prettyId || "-" },
|
||||
{ key: "country", label: "国家/性别", render: (user) => `${user.country || "-"} / ${user.gender || "-"}` },
|
||||
{ key: "level", label: "等级", render: (user) => `财富 ${user.wealthLevel} / 魅力 ${user.charmLevel} / VIP ${user.vipLevel}` },
|
||||
{ key: "status", label: "状态", render: (user) => <StatusChip status={user.banned ? "banned" : user.status} /> },
|
||||
{ key: "actions", label: "操作", render: (user) => <Stack direction="row" flexWrap="wrap" gap={0.5}>{canEdit ? <Button onClick={() => setDialog({ type: "edit", user })} startIcon={<EditOutlined />}>编辑</Button> : null}{canGrantLevel ? <Button onClick={() => setDialog({ type: "level", user })} startIcon={<TrendingUpOutlined />}>等级</Button> : null}{canBan && !user.banned ? <Button color="error" onClick={() => setDialog({ type: "ban", user })} startIcon={<GppBadOutlined />}>封禁</Button> : null}</Stack> }
|
||||
], [canBan, canEdit, canGrantLevel]);
|
||||
|
||||
return (
|
||||
<ExternalPage title="用户管理">
|
||||
<Stack className="external-filter-bar" component="form" direction={{ xs: "column", sm: "row" }} spacing={1.5} onSubmit={(event) => { event.preventDefault(); setQuery({ ...filters, page: 1 }); }}>
|
||||
<TextField label="用户 ID / 短 ID / 名称" value={filters.keyword} onChange={(event) => setFilters({ ...filters, keyword: event.target.value })} />
|
||||
<TextField label="状态" select value={filters.status} onChange={(event) => setFilters({ ...filters, status: event.target.value })}><MenuItem value="">全部状态</MenuItem><MenuItem value="active">正常</MenuItem><MenuItem value="banned">已封禁</MenuItem></TextField>
|
||||
<Button type="submit" variant="contained">查询</Button>
|
||||
</Stack>
|
||||
<ExternalPageState error={error} loading={loading} onRetry={reload} />
|
||||
{!loading && !error ? <><ResponsiveDataList columns={columns} items={data.items} rowKey={(item) => item.id} /><PagePagination {...data} onChange={(page) => setQuery({ ...query, page })} /></> : null}
|
||||
<UserEditDialog loading={submitting} open={dialog.type === "edit"} user={dialog.user} onClose={closeDialog} onSubmit={(form) => execute(() => updateUser(dialog.user.id, form), "用户信息已更新")} />
|
||||
<UserBanDialog loading={submitting} open={dialog.type === "ban"} user={dialog.user} onClose={closeDialog} onSubmit={(form) => execute(() => banUser(dialog.user.id, { expires_at_ms: Number(form.days) === 0 ? 0 : Date.now() + Number(form.days) * 86400000, reason: form.reason.trim() }), "用户已封禁")} />
|
||||
<UserLevelDialog loading={submitting} open={dialog.type === "level"} user={dialog.user} onClose={closeDialog} onSubmit={(form) => execute(() => form.track === "vip"
|
||||
? grantUserVip(dialog.user.id, { commandId: createLevelCommandId(), durationMs: Number(form.days) * 86400000, level: Number(form.level), reason: form.reason.trim() })
|
||||
: updateUserLevels(dialog.user.id, { adjustments: [{ duration_days: Number(form.days), level: Number(form.level), track: form.track }], reason: form.reason.trim() }), "用户等级已下发")} />
|
||||
</ExternalPage>
|
||||
);
|
||||
}
|
||||
|
||||
function createLevelCommandId() {
|
||||
return `external-level-grant-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`;
|
||||
}
|
||||
16
external-admin/src/shared/ConfirmDialog.jsx
Normal file
16
external-admin/src/shared/ConfirmDialog.jsx
Normal file
@ -0,0 +1,16 @@
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
|
||||
export function ConfirmDialog({ confirmColor = "primary", content, loading, onClose, onConfirm, open, title }) {
|
||||
return (
|
||||
<Dialog fullWidth maxWidth="xs" open={open} onClose={loading ? undefined : onClose}>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent><DialogContentText>{content}</DialogContentText></DialogContent>
|
||||
<DialogActions><Button disabled={loading} onClick={onClose}>取消</Button><Button color={confirmColor} disabled={loading} onClick={onConfirm} variant="contained">确认</Button></DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
64
external-admin/src/shared/ExternalImageUploadField.jsx
Normal file
64
external-admin/src/shared/ExternalImageUploadField.jsx
Normal file
@ -0,0 +1,64 @@
|
||||
import CloudUploadOutlined from "@mui/icons-material/CloudUploadOutlined";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useRef, useState } from "react";
|
||||
import { uploadExternalImage } from "../api/business.js";
|
||||
|
||||
export function ExternalImageUploadField({ disabled, label, onChange, value }) {
|
||||
const inputRef = useRef(null);
|
||||
const [error, setError] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const handleFile = async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setError("请选择图片文件");
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError("图片不能超过 10MB");
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
setUploading(true);
|
||||
try {
|
||||
const url = await uploadExternalImage(file);
|
||||
if (!url) {
|
||||
throw new Error("上传结果缺少图片地址");
|
||||
}
|
||||
onChange(url);
|
||||
} catch (uploadError) {
|
||||
setError(uploadError.message || "图片上传失败");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<Typography color="text.secondary" variant="body2">{label}</Typography>
|
||||
<Box className="external-image-field">
|
||||
{value ? <img alt={`${label}预览`} src={value} /> : <Box className="external-image-placeholder">暂无图片</Box>}
|
||||
<Button
|
||||
disabled={disabled || uploading}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
startIcon={uploading ? <CircularProgress size={16} /> : <CloudUploadOutlined />}
|
||||
variant="outlined"
|
||||
>
|
||||
{uploading ? "上传中" : value ? "更换图片" : "上传图片"}
|
||||
</Button>
|
||||
<input accept="image/*" hidden ref={inputRef} type="file" onChange={handleFile} />
|
||||
</Box>
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
117
external-admin/src/shared/PageComponents.jsx
Normal file
117
external-admin/src/shared/PageComponents.jsx
Normal file
@ -0,0 +1,117 @@
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Card from "@mui/material/Card";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
export function ExternalPage({ actions, children, title }) {
|
||||
return (
|
||||
<Box className="external-page">
|
||||
<Stack alignItems={{ md: "center" }} direction={{ xs: "column", md: "row" }} justifyContent="space-between" spacing={2}>
|
||||
<Typography component="h1" variant="h5">
|
||||
{title}
|
||||
</Typography>
|
||||
{actions ? <Stack direction="row" flexWrap="wrap" gap={1}>{actions}</Stack> : null}
|
||||
</Stack>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExternalPageState({ error, loading, onRetry }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Box aria-label="正在加载" className="external-skeleton" role="status">
|
||||
<Skeleton height={46} variant="rounded" />
|
||||
<Skeleton height="min(52vh, 520px)" variant="rounded" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
<Button onClick={onRetry} variant="outlined">重新加载</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ResponsiveDataList({ columns, emptyText = "当前无数据", items, rowKey }) {
|
||||
const theme = useTheme();
|
||||
const mobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
|
||||
if (!items.length) {
|
||||
return <Box className="external-empty">{emptyText}</Box>;
|
||||
}
|
||||
|
||||
if (mobile) {
|
||||
return (
|
||||
<Stack spacing={1.5}>
|
||||
{items.map((item, index) => (
|
||||
<Card key={rowKey(item, index)} variant="outlined">
|
||||
<CardContent className="external-mobile-card">
|
||||
{columns.map((column) => (
|
||||
<Box className="external-mobile-row" key={column.key}>
|
||||
<Typography color="text.secondary" component="span" variant="body2">{column.label}</Typography>
|
||||
<Box>{column.render(item)}</Box>
|
||||
</Box>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer className="external-table-container">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>{columns.map((column) => <TableCell key={column.key}>{column.label}</TableCell>)}</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map((item, index) => (
|
||||
<TableRow hover key={rowKey(item, index)}>
|
||||
{columns.map((column) => <TableCell key={column.key}>{column.render(item)}</TableCell>)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusChip({ status }) {
|
||||
const normalized = String(status || "-").toLowerCase();
|
||||
const enabled = ["active", "enabled", "running", "normal", "unbanned"].includes(normalized);
|
||||
const dangerous = ["banned", "disabled", "failed", "blocked"].includes(normalized);
|
||||
return <Chip color={enabled ? "success" : dangerous ? "error" : "default"} label={status || "-"} size="small" variant="outlined" />;
|
||||
}
|
||||
|
||||
export function PagePagination({ page, pageSize, total, onChange }) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
return (
|
||||
<Stack alignItems="center" direction="row" justifyContent="space-between" spacing={1}>
|
||||
<Typography color="text.secondary" variant="body2">共 {total} 条</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button disabled={page <= 1} onClick={() => onChange(page - 1)} variant="outlined">上一页</Button>
|
||||
<Typography className="external-page-number" variant="body2">{page} / {totalPages}</Typography>
|
||||
<Button disabled={page >= totalPages} onClick={() => onChange(page + 1)} variant="outlined">下一页</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
41
external-admin/src/shared/usePagedResource.js
Normal file
41
external-admin/src/shared/usePagedResource.js
Normal file
@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const EMPTY_PAGE = { items: [], page: 1, pageSize: 20, total: 0 };
|
||||
|
||||
export function usePagedResource(loader) {
|
||||
const [data, setData] = useState(EMPTY_PAGE);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
const reload = useCallback(() => setVersion((current) => current + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
// Start inside the promise chain so synchronous validation errors use the same visible error state as network failures.
|
||||
Promise.resolve()
|
||||
.then(loader)
|
||||
.then((result) => {
|
||||
if (active) {
|
||||
setData(result || EMPTY_PAGE);
|
||||
}
|
||||
})
|
||||
.catch((requestError) => {
|
||||
if (active) {
|
||||
setError(requestError.message || "数据加载失败");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [loader, version]);
|
||||
|
||||
return { data, error, loading, reload };
|
||||
}
|
||||
299
external-admin/src/styles/index.css
Normal file
299
external-admin/src/styles/index.css
Normal file
@ -0,0 +1,299 @@
|
||||
html,
|
||||
body,
|
||||
#external-admin-root {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-page);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-system);
|
||||
}
|
||||
|
||||
.external-login-page {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: max(24px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
|
||||
background: radial-gradient(circle at 50% 0, rgba(37, 99, 235, 0.13), transparent 42%), var(--bg-page);
|
||||
}
|
||||
|
||||
.external-login-card {
|
||||
width: min(100%, 420px);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-panel);
|
||||
}
|
||||
|
||||
.external-login-card .MuiCardContent-root {
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.external-login-avatar,
|
||||
.external-logo-mark {
|
||||
background: var(--brand-gradient) !important;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.external-full-state {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.external-shell,
|
||||
.external-main {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.external-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.96) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.external-header .MuiToolbar-root {
|
||||
min-height: 64px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.external-user-avatar {
|
||||
width: 34px !important;
|
||||
height: 34px !important;
|
||||
font-size: 13px !important;
|
||||
background: var(--primary) !important;
|
||||
}
|
||||
|
||||
.external-sidebar {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-sidebar);
|
||||
}
|
||||
|
||||
.external-logo {
|
||||
width: 248px;
|
||||
min-height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.external-logo-mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
font-weight: 800;
|
||||
box-shadow: var(--brand-shadow);
|
||||
}
|
||||
|
||||
.external-nav {
|
||||
padding: 12px !important;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.external-nav .MuiListItemButton-root {
|
||||
min-height: 44px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard), transform var(--motion-base) var(--ease-emphasized);
|
||||
}
|
||||
|
||||
.external-nav .MuiListItemButton-root:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.external-nav .MuiListItemButton-root.Mui-selected,
|
||||
.external-nav .MuiListItemButton-root.Mui-selected:hover {
|
||||
background: var(--primary-surface-strong);
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.external-nav .MuiListItemIcon-root {
|
||||
min-width: 38px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.external-main {
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.external-page {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
animation: external-page-enter var(--motion-slow) var(--ease-emphasized) both;
|
||||
}
|
||||
|
||||
.external-session-card,
|
||||
.external-table-container,
|
||||
.external-filter-bar,
|
||||
.external-form-panel,
|
||||
.external-empty {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.external-filter-bar,
|
||||
.external-form-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.external-filter-bar .MuiTextField-root {
|
||||
min-width: min(100%, 220px);
|
||||
}
|
||||
|
||||
.external-capability-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.external-capability-card {
|
||||
min-height: 150px;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.external-capability-card.is-enabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.external-capability-card.is-enabled:hover {
|
||||
border-color: var(--primary-border);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.external-capability-card .MuiCardActionArea-root,
|
||||
.external-capability-card .MuiCardContent-root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.external-table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.external-table-container .MuiTableHead-root {
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.external-table-container .MuiTableCell-root {
|
||||
white-space: nowrap;
|
||||
border-color: var(--row-border);
|
||||
}
|
||||
|
||||
.external-empty {
|
||||
min-height: 200px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.external-skeleton {
|
||||
min-height: 50vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.external-page-number {
|
||||
min-width: 64px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.external-mobile-card .external-mobile-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(92px, 0.38fr) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--row-border);
|
||||
}
|
||||
|
||||
.external-mobile-card .external-mobile-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.external-dialog-fields {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.external-image-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 92px;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.external-image-field img,
|
||||
.external-image-placeholder {
|
||||
width: 104px;
|
||||
height: 68px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.external-banner-thumb {
|
||||
width: 88px;
|
||||
height: 50px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.external-image-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@keyframes external-page-enter {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
.external-capability-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.external-page { padding: 18px; }
|
||||
.external-capability-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.external-login-card .MuiCardContent-root { padding: 24px 20px; }
|
||||
.external-page { padding: 14px; gap: 14px; }
|
||||
.external-capability-grid { grid-template-columns: 1fr; }
|
||||
.external-capability-card { min-height: 128px; }
|
||||
.external-filter-bar .MuiTextField-root { width: 100%; }
|
||||
.external-image-field { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@ -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 }),
|
||||
|
||||
@ -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配置" },
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
121
src/features/external-admin-users/api.test.ts
Normal file
121
src/features/external-admin-users/api.test.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { setAccessToken, setSelectedAppCode } from "@/shared/api/request";
|
||||
import {
|
||||
createExternalAdminUser,
|
||||
listExternalAdminUsers,
|
||||
lookupExternalAdminUserTarget,
|
||||
resetExternalAdminUserPassword,
|
||||
updateExternalAdminUserStatus,
|
||||
} from "./api";
|
||||
|
||||
afterEach(() => {
|
||||
setAccessToken("");
|
||||
setSelectedAppCode("lalu");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("external admin APIs pin every read and write to the explicit App scope", async () => {
|
||||
setSelectedAppCode("lalu");
|
||||
const externalUser = externalUserFixture();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn()
|
||||
.mockResolvedValueOnce(envelope({ items: [externalUser], page: 1, page_size: 50, total: 1 }))
|
||||
.mockResolvedValueOnce(envelope({ linked_user: externalUser.linked_user }))
|
||||
.mockResolvedValueOnce(envelope(externalUser, 201))
|
||||
.mockResolvedValueOnce(envelope({ ...externalUser, status: "disabled" }))
|
||||
.mockResolvedValueOnce(envelope(externalUser)),
|
||||
);
|
||||
|
||||
const list = await listExternalAdminUsers(" Huwaa ", {
|
||||
keyword: "ops.fami",
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
status: "active",
|
||||
});
|
||||
const target = await lookupExternalAdminUserTarget("huwaa", "VIP-1001");
|
||||
await createExternalAdminUser("huwaa", {
|
||||
password: "StrongPass123!",
|
||||
targetUserId: "user-1001",
|
||||
username: "ops.fami",
|
||||
});
|
||||
await updateExternalAdminUserStatus("huwaa", 71, "disabled");
|
||||
await resetExternalAdminUserPassword("huwaa", 71, "NextStrongPass123!");
|
||||
|
||||
const calls = vi.mocked(fetch).mock.calls;
|
||||
expect(calls.map(([, init]) => init?.method)).toEqual(["GET", "GET", "POST", "PATCH", "POST"]);
|
||||
calls.forEach(([, init]) => {
|
||||
expect(init?.headers).toMatchObject({ "X-App-Code": "huwaa" });
|
||||
});
|
||||
expect(String(calls[0][0])).toContain("/api/v1/admin/external-admin-users?");
|
||||
expect(String(calls[0][0])).toContain("keyword=ops.fami");
|
||||
expect(String(calls[0][0])).toContain("status=active");
|
||||
expect(String(calls[1][0])).toContain("/api/v1/admin/external-admin-users/target?display_user_id=VIP-1001");
|
||||
expect(String(calls[3][0])).toContain("/api/v1/admin/external-admin-users/71/status");
|
||||
expect(String(calls[4][0])).toContain("/api/v1/admin/external-admin-users/71/password");
|
||||
expect(requestBody(calls[2][1])).toEqual({
|
||||
password: "StrongPass123!",
|
||||
targetUserId: "user-1001",
|
||||
username: "ops.fami",
|
||||
});
|
||||
expect(requestBody(calls[3][1])).toEqual({ status: "disabled" });
|
||||
expect(requestBody(calls[4][1])).toEqual({ password: "NextStrongPass123!" });
|
||||
expect(list).toMatchObject({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1,
|
||||
items: [
|
||||
{
|
||||
appCode: "huwaa",
|
||||
createdAtMs: 1784073600000,
|
||||
createdByAdminId: 9,
|
||||
id: 71,
|
||||
linkedUser: {
|
||||
defaultDisplayUserId: "1001",
|
||||
prettyDisplayUserId: "VIP-1001",
|
||||
userId: "user-1001",
|
||||
},
|
||||
permissions: ["user:list", "room:edit"],
|
||||
status: "active",
|
||||
username: "ops.fami",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(target).toMatchObject({
|
||||
defaultDisplayUserId: "1001",
|
||||
prettyDisplayUserId: "VIP-1001",
|
||||
userId: "user-1001",
|
||||
});
|
||||
});
|
||||
|
||||
function envelope(data: unknown, status = 200) {
|
||||
return new Response(JSON.stringify({ code: 0, data }), { status });
|
||||
}
|
||||
|
||||
function requestBody(init: RequestInit | undefined) {
|
||||
return JSON.parse(String(init?.body || "{}"));
|
||||
}
|
||||
|
||||
function externalUserFixture() {
|
||||
return {
|
||||
app_code: "huwaa",
|
||||
created_at_ms: "1784073600000",
|
||||
created_by_admin_id: 9,
|
||||
id: 71,
|
||||
last_login_at_ms: null,
|
||||
linked_user: {
|
||||
avatar: "https://cdn.example.com/u.png",
|
||||
default_display_user_id: "1001",
|
||||
display_user_id: "VIP-1001",
|
||||
pretty_display_user_id: "VIP-1001",
|
||||
pretty_id: "pretty-1",
|
||||
status: "active",
|
||||
user_id: "user-1001",
|
||||
username: "Fami user",
|
||||
},
|
||||
permissions: ["user:list", "room:edit"],
|
||||
status: "active",
|
||||
updated_at_ms: "1784077200000",
|
||||
username: "ops.fami",
|
||||
};
|
||||
}
|
||||
223
src/features/external-admin-users/api.ts
Normal file
223
src/features/external-admin-users/api.ts
Normal file
@ -0,0 +1,223 @@
|
||||
import { apiRequest } from "@/shared/api/request";
|
||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import type { ApiPage, CoinLedgerUserDto, EntityId, PageQuery } from "@/shared/api/types";
|
||||
|
||||
export type ExternalAdminUserStatus = "active" | "disabled";
|
||||
|
||||
export interface ExternalAdminLinkedUserDto extends CoinLedgerUserDto {
|
||||
defaultDisplayUserId?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ExternalAdminUserDto {
|
||||
appCode?: string;
|
||||
createdAtMs?: number;
|
||||
createdByAdminId?: EntityId;
|
||||
id: EntityId;
|
||||
lastLoginAtMs?: number;
|
||||
linkedUser: ExternalAdminLinkedUserDto;
|
||||
permissions: string[];
|
||||
status: ExternalAdminUserStatus;
|
||||
updatedAtMs?: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface ExternalAdminUserTargetDto extends ExternalAdminLinkedUserDto {
|
||||
existingExternalAdminUser?: ExternalAdminUserDto;
|
||||
}
|
||||
|
||||
export interface CreateExternalAdminUserPayload {
|
||||
password: string;
|
||||
targetUserId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export async function listExternalAdminUsers(
|
||||
appCode: string,
|
||||
query: PageQuery = {},
|
||||
): Promise<ApiPage<ExternalAdminUserDto>> {
|
||||
const endpoint = API_ENDPOINTS.listExternalAdminUsers;
|
||||
const data = await apiRequest<RawPage<RawRecord>>(apiEndpointPath(API_OPERATIONS.listExternalAdminUsers), {
|
||||
headers: appScopeHeaders(appCode),
|
||||
method: endpoint.method,
|
||||
query,
|
||||
});
|
||||
return normalizePage(data, query);
|
||||
}
|
||||
|
||||
export async function lookupExternalAdminUserTarget(
|
||||
appCode: string,
|
||||
displayUserId: string,
|
||||
): Promise<ExternalAdminUserTargetDto> {
|
||||
const endpoint = API_ENDPOINTS.lookupExternalAdminUserTarget;
|
||||
const data = await apiRequest<RawRecord>(apiEndpointPath(API_OPERATIONS.lookupExternalAdminUserTarget), {
|
||||
headers: appScopeHeaders(appCode),
|
||||
method: endpoint.method,
|
||||
query: { display_user_id: displayUserId },
|
||||
});
|
||||
return normalizeTarget(data);
|
||||
}
|
||||
|
||||
export async function createExternalAdminUser(
|
||||
appCode: string,
|
||||
payload: CreateExternalAdminUserPayload,
|
||||
): Promise<ExternalAdminUserDto> {
|
||||
const endpoint = API_ENDPOINTS.createExternalAdminUser;
|
||||
const data = await apiRequest<RawRecord, CreateExternalAdminUserPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.createExternalAdminUser),
|
||||
{
|
||||
body: payload,
|
||||
headers: appScopeHeaders(appCode),
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
return normalizeExternalAdminUser(data);
|
||||
}
|
||||
|
||||
export async function updateExternalAdminUserStatus(
|
||||
appCode: string,
|
||||
id: EntityId,
|
||||
status: ExternalAdminUserStatus,
|
||||
): Promise<ExternalAdminUserDto> {
|
||||
const endpoint = API_ENDPOINTS.updateExternalAdminUserStatus;
|
||||
const data = await apiRequest<RawRecord, { status: ExternalAdminUserStatus }>(
|
||||
apiEndpointPath(API_OPERATIONS.updateExternalAdminUserStatus, { id }),
|
||||
{
|
||||
body: { status },
|
||||
headers: appScopeHeaders(appCode),
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
return normalizeExternalAdminUser(data);
|
||||
}
|
||||
|
||||
export async function resetExternalAdminUserPassword(
|
||||
appCode: string,
|
||||
id: EntityId,
|
||||
password: string,
|
||||
): Promise<ExternalAdminUserDto> {
|
||||
const endpoint = API_ENDPOINTS.resetExternalAdminUserPassword;
|
||||
const data = await apiRequest<RawRecord, { password: string }>(
|
||||
apiEndpointPath(API_OPERATIONS.resetExternalAdminUserPassword, { id }),
|
||||
{
|
||||
body: { password },
|
||||
headers: appScopeHeaders(appCode),
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
return normalizeExternalAdminUser(data);
|
||||
}
|
||||
|
||||
function appScopeHeaders(appCode: string) {
|
||||
// 外管账号是强 App 隔离数据;显式固定请求头,避免切换 App 后全局请求上下文把写操作送到另一租户。
|
||||
return { "X-App-Code": String(appCode || "").trim().toLowerCase() };
|
||||
}
|
||||
|
||||
type RawRecord = Record<string, unknown>;
|
||||
|
||||
interface RawPage<TItem> {
|
||||
items?: TItem[];
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
pageSize?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
function normalizePage(data: RawPage<RawRecord>, query: PageQuery): ApiPage<ExternalAdminUserDto> {
|
||||
return {
|
||||
items: (Array.isArray(data?.items) ? data.items : []).map(normalizeExternalAdminUser),
|
||||
page: numberValue(data?.page, Number(query.page || 1)),
|
||||
pageSize: numberValue(data?.pageSize ?? data?.page_size, Number(query.page_size || 50)),
|
||||
total: numberValue(data?.total),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExternalAdminUser(raw: RawRecord): ExternalAdminUserDto {
|
||||
const item = asRecord(raw) || {};
|
||||
const linkedUser = normalizeUser(
|
||||
asRecord(item.linkedUser) ||
|
||||
asRecord(item.linked_user) ||
|
||||
asRecord(item.user) ||
|
||||
asRecord(item.targetUser) ||
|
||||
asRecord(item.target_user) ||
|
||||
item,
|
||||
);
|
||||
const status = stringValue(item.status).toLowerCase();
|
||||
return {
|
||||
appCode: stringValue(item.appCode ?? item.app_code),
|
||||
createdAtMs: optionalNumber(item.createdAtMs ?? item.created_at_ms),
|
||||
createdByAdminId: entityValue(item.createdByAdminId ?? item.created_by_admin_id),
|
||||
id: entityValue(item.id ?? item.externalAdminUserId ?? item.external_admin_user_id),
|
||||
lastLoginAtMs: optionalNumber(item.lastLoginAtMs ?? item.last_login_at_ms),
|
||||
linkedUser,
|
||||
permissions: stringArray(item.permissions),
|
||||
status: status === "disabled" ? "disabled" : "active",
|
||||
updatedAtMs: optionalNumber(item.updatedAtMs ?? item.updated_at_ms),
|
||||
username: stringValue(item.username ?? item.account ?? item.accountName ?? item.account_name),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTarget(raw: RawRecord): ExternalAdminUserTargetDto {
|
||||
const item = asRecord(raw) || {};
|
||||
const user = normalizeUser(
|
||||
asRecord(item.linkedUser) ||
|
||||
asRecord(item.linked_user) ||
|
||||
asRecord(item.user) ||
|
||||
asRecord(item.targetUser) ||
|
||||
asRecord(item.target_user) ||
|
||||
item,
|
||||
);
|
||||
const existing =
|
||||
asRecord(item.existingExternalAdminUser) ||
|
||||
asRecord(item.existing_external_admin_user) ||
|
||||
asRecord(item.existingAccount) ||
|
||||
asRecord(item.existing_account);
|
||||
return {
|
||||
...user,
|
||||
existingExternalAdminUser: existing ? normalizeExternalAdminUser(existing) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUser(raw: RawRecord): ExternalAdminLinkedUserDto {
|
||||
const user = asRecord(raw) || {};
|
||||
return {
|
||||
avatar: stringValue(user.avatar),
|
||||
defaultDisplayUserId: stringValue(user.defaultDisplayUserId ?? user.default_display_user_id),
|
||||
displayUserId: stringValue(
|
||||
user.displayUserId ?? user.display_user_id ?? user.defaultDisplayUserId ?? user.default_display_user_id,
|
||||
),
|
||||
prettyDisplayUserId: stringValue(user.prettyDisplayUserId ?? user.pretty_display_user_id),
|
||||
prettyId: stringValue(user.prettyId ?? user.pretty_id),
|
||||
status: stringValue(user.status),
|
||||
userId: stringValue(user.userId ?? user.user_id ?? user.targetUserId ?? user.target_user_id),
|
||||
username: stringValue(user.username ?? user.name),
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): RawRecord | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as RawRecord) : undefined;
|
||||
}
|
||||
|
||||
function entityValue(value: unknown): EntityId {
|
||||
return typeof value === "number" ? value : stringValue(value);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, fallback = 0) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
|
||||
function optionalNumber(value: unknown) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return undefined;
|
||||
}
|
||||
return numberValue(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
function stringArray(value: unknown) {
|
||||
return Array.isArray(value) ? value.map(stringValue).filter(Boolean) : [];
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
import SearchOutlined from "@mui/icons-material/SearchOutlined";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import {
|
||||
AdminFormDialog,
|
||||
AdminFormFieldGrid,
|
||||
AdminFormSection,
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||
import styles from "@/features/external-admin-users/external-admin-users.module.css";
|
||||
|
||||
export function ExternalAdminUserFormDialog({
|
||||
abilities,
|
||||
form,
|
||||
loading,
|
||||
lookupLoading,
|
||||
onClose,
|
||||
onDisplayUserIdChange,
|
||||
onFormChange,
|
||||
onLookup,
|
||||
onSubmit,
|
||||
open,
|
||||
targetUser,
|
||||
}) {
|
||||
const existingAccount = targetUser?.existingExternalAdminUser;
|
||||
|
||||
return (
|
||||
<AdminFormDialog
|
||||
disabled={!abilities.canCreate}
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="large"
|
||||
submitDisabled={!abilities.canCreate || loading || !targetUser?.userId || Boolean(existingAccount)}
|
||||
submitLabel="创建"
|
||||
title="创建外管用户"
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<AdminFormSection title="绑定 App 用户">
|
||||
<div className={styles.lookupRow}>
|
||||
<TextField
|
||||
autoComplete="off"
|
||||
disabled={!abilities.canCreate || loading}
|
||||
label="用户短 ID / 靓号"
|
||||
required
|
||||
value={form.displayUserId}
|
||||
onChange={(event) => onDisplayUserIdChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
onLookup();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className={styles.lookupButton}
|
||||
disabled={!abilities.canCreate || loading || lookupLoading}
|
||||
startIcon={<SearchOutlined fontSize="small" />}
|
||||
onClick={onLookup}
|
||||
>
|
||||
{lookupLoading ? "搜索中" : "搜索"}
|
||||
</Button>
|
||||
</div>
|
||||
{targetUser ? (
|
||||
<div className={styles.targetPanel}>
|
||||
<AdminUserIdentity
|
||||
openInAppUserDetail
|
||||
rows={[targetUser.displayUserId, targetUser.userId]}
|
||||
size="large"
|
||||
user={targetUser}
|
||||
/>
|
||||
{existingAccount ? (
|
||||
<div className={styles.existingWarning}>
|
||||
该用户已绑定外管账号 {existingAccount.username || existingAccount.id},不能重复创建。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</AdminFormSection>
|
||||
|
||||
<AdminFormSection title="外管登录账号">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
autoComplete="off"
|
||||
disabled={!abilities.canCreate || loading}
|
||||
label="外管账号"
|
||||
required
|
||||
value={form.username}
|
||||
onChange={(event) => onFormChange({ username: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
disabled={!abilities.canCreate || loading}
|
||||
label="密码"
|
||||
required
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(event) => onFormChange({ password: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
disabled={!abilities.canCreate || loading}
|
||||
label="确认密码"
|
||||
required
|
||||
type="password"
|
||||
value={form.confirmPassword}
|
||||
onChange={(event) => onFormChange({ confirmPassword: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||
|
||||
export function ExternalAdminUserPasswordDialog({
|
||||
abilities,
|
||||
form,
|
||||
loading,
|
||||
onClose,
|
||||
onFormChange,
|
||||
onSubmit,
|
||||
user,
|
||||
}) {
|
||||
return (
|
||||
<AdminFormDialog
|
||||
disabled={!abilities.canResetPassword}
|
||||
loading={loading}
|
||||
open={Boolean(user)}
|
||||
size="compact"
|
||||
submitLabel="重置密码"
|
||||
title="重置外管密码"
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<AdminFormSection title="外管用户">
|
||||
<AdminUserIdentity
|
||||
rows={[user?.linkedUser?.displayUserId, user?.linkedUser?.userId]}
|
||||
user={user?.linkedUser}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="新密码">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
disabled={!abilities.canResetPassword || loading}
|
||||
label="密码"
|
||||
required
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(event) => onFormChange({ password: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
disabled={!abilities.canResetPassword || loading}
|
||||
label="确认密码"
|
||||
required
|
||||
type="password"
|
||||
value={form.confirmPassword}
|
||||
onChange={(event) => onFormChange({ confirmPassword: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
@ -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%;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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",
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,217 @@
|
||||
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
|
||||
import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined";
|
||||
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
|
||||
import { ExternalAdminUserFormDialog } from "@/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx";
|
||||
import { ExternalAdminUserPasswordDialog } from "@/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx";
|
||||
import styles from "@/features/external-admin-users/external-admin-users.module.css";
|
||||
import { useExternalAdminUsersPage } from "@/features/external-admin-users/hooks/useExternalAdminUsersPage.js";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminFilterSelect,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
AdminSearchBox,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
|
||||
const statusOptions = [
|
||||
["", "全部状态"],
|
||||
["active", "启用"],
|
||||
["disabled", "停用"],
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "linkedUser",
|
||||
label: "绑定 App 用户",
|
||||
render: (item) => (
|
||||
<AdminUserIdentity
|
||||
openInAppUserDetail
|
||||
rows={[item.linkedUser?.displayUserId, item.linkedUser?.userId]}
|
||||
user={item.linkedUser}
|
||||
/>
|
||||
),
|
||||
width: "minmax(250px, 1.2fr)",
|
||||
},
|
||||
{
|
||||
key: "username",
|
||||
label: "外管账号",
|
||||
render: (item) => (
|
||||
<div className={styles.accountCell}>
|
||||
<span className={styles.accountName}>{item.username || "-"}</span>
|
||||
<span className={styles.accountMeta}>ID {item.id || "-"}</span>
|
||||
</div>
|
||||
),
|
||||
width: "minmax(190px, 0.95fr)",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (item, _index, context) => {
|
||||
const page = context?.page;
|
||||
// 外管状态和密码都是账号级敏感写操作;同页串行化可避免两个请求互相覆盖全局 loading 状态。
|
||||
const accountActionInFlight = Boolean(page?.loadingAction);
|
||||
return (
|
||||
<AdminSwitch
|
||||
checked={item.status === "active"}
|
||||
checkedLabel="启用"
|
||||
disabled={!page?.abilities.canStatus || accountActionInFlight}
|
||||
label={item.status === "active" ? "停用外管用户" : "启用外管用户"}
|
||||
uncheckedLabel="停用"
|
||||
onChange={() => page.toggleStatus(item)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
},
|
||||
{
|
||||
key: "permissions",
|
||||
label: "外管权限",
|
||||
render: (item) => (item.permissions?.length ? `${item.permissions.length} 项` : "-"),
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
},
|
||||
{
|
||||
key: "createdByAdminId",
|
||||
label: "创建人",
|
||||
render: (item) => item.createdByAdminId || "-",
|
||||
width: "minmax(130px, 0.7fr)",
|
||||
},
|
||||
{
|
||||
key: "lastLoginAtMs",
|
||||
label: "最近登录",
|
||||
render: (item) => formatMillis(item.lastLoginAtMs),
|
||||
width: "minmax(170px, 0.85fr)",
|
||||
},
|
||||
{
|
||||
key: "createdAtMs",
|
||||
label: "创建时间",
|
||||
render: (item) => formatMillis(item.createdAtMs),
|
||||
width: "minmax(170px, 0.85fr)",
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item, _index, context) => {
|
||||
const page = context?.page;
|
||||
return (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton
|
||||
disabled={!page?.abilities.canResetPassword || Boolean(page.loadingAction)}
|
||||
label="重置密码"
|
||||
onClick={() => page.openPassword(item)}
|
||||
>
|
||||
<PasswordOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
);
|
||||
},
|
||||
resizable: false,
|
||||
width: "88px",
|
||||
},
|
||||
];
|
||||
|
||||
export function ExternalAdminUsersPage() {
|
||||
const page = useExternalAdminUsersPage();
|
||||
const items = page.data.items || [];
|
||||
const total = Number(page.data.total || 0);
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
<>
|
||||
<AdminActionIconButton
|
||||
component="a"
|
||||
href="/external-admin/"
|
||||
label="打开外管后台"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<OpenInNewOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
{page.abilities.canCreate ? (
|
||||
<AdminActionIconButton
|
||||
disabled={Boolean(page.loadingAction)}
|
||||
label="创建外管用户"
|
||||
primary
|
||||
onClick={page.openCreate}
|
||||
>
|
||||
<PersonAddAltOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
filters={
|
||||
<>
|
||||
<AdminSearchBox
|
||||
label="搜索"
|
||||
placeholder="外管账号、短 ID"
|
||||
value={page.keyword}
|
||||
onChange={page.setKeyword}
|
||||
/>
|
||||
<AdminFilterSelect
|
||||
label="状态"
|
||||
options={statusOptions}
|
||||
value={page.status}
|
||||
onChange={page.setStatus}
|
||||
/>
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.keyword && !page.status}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
context={{ page }}
|
||||
items={items}
|
||||
minWidth="1350px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
onPageChange: page.setPage,
|
||||
page: page.page,
|
||||
pageSize: page.data.pageSize || page.pageSize,
|
||||
total,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(item) => item.id || `${item.appCode}-${item.username}`}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<ExternalAdminUserFormDialog
|
||||
abilities={page.abilities}
|
||||
form={page.createForm}
|
||||
loading={page.loadingAction === "create"}
|
||||
lookupLoading={page.lookupLoading}
|
||||
open={page.createOpen}
|
||||
targetUser={page.targetUser}
|
||||
onClose={page.closeCreate}
|
||||
onDisplayUserIdChange={page.setDisplayUserId}
|
||||
onFormChange={page.setCreateForm}
|
||||
onLookup={page.lookupTarget}
|
||||
onSubmit={page.submitCreate}
|
||||
/>
|
||||
<ExternalAdminUserPasswordDialog
|
||||
abilities={page.abilities}
|
||||
form={page.passwordForm}
|
||||
loading={page.loadingAction.startsWith("password:")}
|
||||
user={page.passwordUser}
|
||||
onClose={page.closePassword}
|
||||
onFormChange={page.setPasswordForm}
|
||||
onSubmit={page.submitPassword}
|
||||
/>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
13
src/features/external-admin-users/permissions.js
Normal file
13
src/features/external-admin-users/permissions.js
Normal file
@ -0,0 +1,13 @@
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export function useExternalAdminUserAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.externalAdminUserCreate),
|
||||
canResetPassword: can(PERMISSIONS.externalAdminUserResetPassword),
|
||||
canStatus: can(PERMISSIONS.externalAdminUserStatus),
|
||||
canView: can(PERMISSIONS.externalAdminUserView),
|
||||
};
|
||||
}
|
||||
13
src/features/external-admin-users/routes.js
Normal file
13
src/features/external-admin-users/routes.js
Normal file
@ -0,0 +1,13 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const externalAdminUserRoutes = [
|
||||
{
|
||||
label: "外管用户",
|
||||
loader: () =>
|
||||
import("./pages/ExternalAdminUsersPage.jsx").then((module) => module.ExternalAdminUsersPage),
|
||||
menuCode: MENU_CODES.operationExternalAdminUsers,
|
||||
pageKey: "operation-external-admin-users",
|
||||
path: "/operations/external-admin-users",
|
||||
permission: PERMISSIONS.externalAdminUserView,
|
||||
},
|
||||
];
|
||||
65
src/features/external-admin-users/schema.test.ts
Normal file
65
src/features/external-admin-users/schema.test.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
externalAdminUserCreateFormSchema,
|
||||
externalAdminUserLookupSchema,
|
||||
externalAdminUserPasswordFormSchema,
|
||||
} from "./schema";
|
||||
|
||||
describe("external admin user schemas", () => {
|
||||
test("accepts Unicode pretty IDs and trims login identifiers", () => {
|
||||
expect(externalAdminUserLookupSchema.parse({ displayUserId: " 靓号-一号 " })).toEqual({
|
||||
displayUserId: "靓号-一号",
|
||||
});
|
||||
|
||||
expect(
|
||||
externalAdminUserCreateFormSchema.parse({
|
||||
confirmPassword: " StrongPass123! ",
|
||||
displayUserId: " VIP-1001 ",
|
||||
password: " StrongPass123! ",
|
||||
username: " ops.fami ",
|
||||
}),
|
||||
).toEqual({
|
||||
displayUserId: "VIP-1001",
|
||||
password: " StrongPass123! ",
|
||||
username: "ops.fami",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps password bytes unchanged and rejects mismatched confirmation", () => {
|
||||
expect(
|
||||
externalAdminUserPasswordFormSchema.parse({
|
||||
confirmPassword: " password-with-spaces ",
|
||||
password: " password-with-spaces ",
|
||||
}),
|
||||
).toEqual({ password: " password-with-spaces " });
|
||||
|
||||
expect(() =>
|
||||
externalAdminUserPasswordFormSchema.parse({
|
||||
confirmPassword: "DifferentPass123!",
|
||||
password: "StrongPass123!",
|
||||
}),
|
||||
).toThrow("两次输入的密码不一致");
|
||||
});
|
||||
|
||||
test("rejects all-whitespace credentials that cannot complete first-login rotation", () => {
|
||||
const result = externalAdminUserCreateFormSchema.safeParse({
|
||||
confirmPassword: " ",
|
||||
displayUserId: "163000",
|
||||
password: " ",
|
||||
username: "fami-operator",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("enforces the login account character set from the API contract", () => {
|
||||
const result = externalAdminUserCreateFormSchema.safeParse({
|
||||
confirmPassword: "StrongPass123!",
|
||||
displayUserId: "1001",
|
||||
password: "StrongPass123!",
|
||||
username: "运营账号",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
63
src/features/external-admin-users/schema.ts
Normal file
63
src/features/external-admin-users/schema.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const displayUserIdSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请输入用户短 ID / 靓号")
|
||||
.refine((value) => Array.from(value).length <= 64, "用户短 ID / 靓号不能超过 64 个字符")
|
||||
// 靓号可能包含 Unicode 字形,不能收窄为纯数字;这里只拦截会破坏查询协议和日志的空白、控制字符。
|
||||
.refine((value) => !/[\s\p{Cc}]/u.test(value), "用户短 ID / 靓号不能包含空白或控制字符");
|
||||
|
||||
const usernameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, "外管账号至少 3 位")
|
||||
.max(64, "外管账号不能超过 64 个字符")
|
||||
// 外管系统使用独立登录标识;限制为跨浏览器、网关和审计日志均稳定的 ASCII 账号字符集。
|
||||
.regex(/^[A-Za-z0-9._-]+$/, "外管账号只能包含字母、数字、点、下划线和连字符");
|
||||
|
||||
// 密码属于登录凭证,不能 trim 或自动改写;前端只做长度与确认值校验,服务端会重复校验并仅持久化密码摘要。
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
.min(8, "密码至少 8 位")
|
||||
.max(72, "密码不能超过 72 个字符")
|
||||
.refine((value) => value.trim().length > 0, "密码不能全部为空白字符");
|
||||
|
||||
export const externalAdminUserLookupSchema = z.object({
|
||||
displayUserId: displayUserIdSchema,
|
||||
});
|
||||
|
||||
export const externalAdminUserCreateFormSchema = z
|
||||
.object({
|
||||
confirmPassword: passwordSchema,
|
||||
displayUserId: displayUserIdSchema,
|
||||
password: passwordSchema,
|
||||
username: usernameSchema,
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.password !== value.confirmPassword) {
|
||||
context.addIssue({ code: "custom", message: "两次输入的密码不一致", path: ["confirmPassword"] });
|
||||
}
|
||||
})
|
||||
.transform(({ confirmPassword: _confirmPassword, ...payload }) => payload);
|
||||
|
||||
export const externalAdminUserPasswordFormSchema = z
|
||||
.object({
|
||||
confirmPassword: passwordSchema,
|
||||
password: passwordSchema,
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.password !== value.confirmPassword) {
|
||||
context.addIssue({ code: "custom", message: "两次输入的密码不一致", path: ["confirmPassword"] });
|
||||
}
|
||||
})
|
||||
.transform(({ confirmPassword: _confirmPassword, ...payload }) => payload);
|
||||
|
||||
export const externalAdminUserStatusSchema = z.object({
|
||||
status: z.enum(["active", "disabled"]),
|
||||
});
|
||||
|
||||
export type ExternalAdminUserCreateForm = z.input<typeof externalAdminUserCreateFormSchema>;
|
||||
export type ExternalAdminUserCreatePayload = z.output<typeof externalAdminUserCreateFormSchema>;
|
||||
export type ExternalAdminUserPasswordForm = z.input<typeof externalAdminUserPasswordFormSchema>;
|
||||
export type ExternalAdminUserPasswordPayload = z.output<typeof externalAdminUserPasswordFormSchema>;
|
||||
@ -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: "支付管理" },
|
||||
|
||||
@ -52,6 +52,7 @@ export const API_OPERATIONS = {
|
||||
createCountry: "createCountry",
|
||||
createEmojiPack: "createEmojiPack",
|
||||
createExploreTab: "createExploreTab",
|
||||
createExternalAdminUser: "createExternalAdminUser",
|
||||
createFinanceCoinSellerRechargeOrder: "createFinanceCoinSellerRechargeOrder",
|
||||
createFullServerNoticeFanout: "createFullServerNoticeFanout",
|
||||
createGift: "createGift",
|
||||
@ -210,6 +211,7 @@ export const API_OPERATIONS = {
|
||||
listEmojiPackCategories: "listEmojiPackCategories",
|
||||
listEmojiPacks: "listEmojiPacks",
|
||||
listExploreTabs: "listExploreTabs",
|
||||
listExternalAdminUsers: "listExternalAdminUsers",
|
||||
listFinanceCoinSellerRechargeOrders: "listFinanceCoinSellerRechargeOrders",
|
||||
listFinanceScopeAssignments: "listFinanceScopeAssignments",
|
||||
listFinanceWithdrawalApplications: "listFinanceWithdrawalApplications",
|
||||
@ -281,6 +283,7 @@ export const API_OPERATIONS = {
|
||||
login: "login",
|
||||
logout: "logout",
|
||||
lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget",
|
||||
lookupExternalAdminUserTarget: "lookupExternalAdminUserTarget",
|
||||
lookupResourceGrantTarget: "lookupResourceGrantTarget",
|
||||
me: "me",
|
||||
navigationMenus: "navigationMenus",
|
||||
@ -301,6 +304,7 @@ export const API_OPERATIONS = {
|
||||
replaceRolePermissions: "replaceRolePermissions",
|
||||
replaceUserAppScopes: "replaceUserAppScopes",
|
||||
replaceUserFinanceScopes: "replaceUserFinanceScopes",
|
||||
resetExternalAdminUserPassword: "resetExternalAdminUserPassword",
|
||||
resetUserPassword: "resetUserPassword",
|
||||
retryActivityTemplateRewardJob: "retryActivityTemplateRewardJob",
|
||||
retryActivityTemplateTaskClaim: "retryActivityTemplateTaskClaim",
|
||||
@ -342,6 +346,7 @@ export const API_OPERATIONS = {
|
||||
updateCumulativeRechargeRewardConfig: "updateCumulativeRechargeRewardConfig",
|
||||
updateDiceConfig: "updateDiceConfig",
|
||||
updateExploreTab: "updateExploreTab",
|
||||
updateExternalAdminUserStatus: "updateExternalAdminUserStatus",
|
||||
updateFirstRechargeRewardConfig: "updateFirstRechargeRewardConfig",
|
||||
updateGift: "updateGift",
|
||||
updateGiftType: "updateGiftType",
|
||||
@ -674,6 +679,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "app-config:update",
|
||||
permissions: ["app-config:update"]
|
||||
},
|
||||
createExternalAdminUser: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createExternalAdminUser,
|
||||
path: "/v1/admin/external-admin-users",
|
||||
permission: "external-admin-user:create",
|
||||
permissions: ["external-admin-user:create"]
|
||||
},
|
||||
createFinanceCoinSellerRechargeOrder: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createFinanceCoinSellerRechargeOrder,
|
||||
@ -1778,6 +1790,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "app-config:view",
|
||||
permissions: ["app-config:view"]
|
||||
},
|
||||
listExternalAdminUsers: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listExternalAdminUsers,
|
||||
path: "/v1/admin/external-admin-users",
|
||||
permission: "external-admin-user:view",
|
||||
permissions: ["external-admin-user:view"]
|
||||
},
|
||||
listFinanceCoinSellerRechargeOrders: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listFinanceCoinSellerRechargeOrders,
|
||||
@ -2269,6 +2288,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "coin-adjustment:create",
|
||||
permissions: ["coin-adjustment:create"]
|
||||
},
|
||||
lookupExternalAdminUserTarget: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.lookupExternalAdminUserTarget,
|
||||
path: "/v1/admin/external-admin-users/target",
|
||||
permission: "external-admin-user:create",
|
||||
permissions: ["external-admin-user:create"]
|
||||
},
|
||||
lookupResourceGrantTarget: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.lookupResourceGrantTarget,
|
||||
@ -2403,6 +2429,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "user:update",
|
||||
permissions: ["user:update"]
|
||||
},
|
||||
resetExternalAdminUserPassword: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.resetExternalAdminUserPassword,
|
||||
path: "/v1/admin/external-admin-users/{id}/password",
|
||||
permission: "external-admin-user:reset-password",
|
||||
permissions: ["external-admin-user:reset-password"]
|
||||
},
|
||||
resetUserPassword: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.resetUserPassword,
|
||||
@ -2688,6 +2721,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "app-config:update",
|
||||
permissions: ["app-config:update"]
|
||||
},
|
||||
updateExternalAdminUserStatus: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.updateExternalAdminUserStatus,
|
||||
path: "/v1/admin/external-admin-users/{id}/status",
|
||||
permission: "external-admin-user:status",
|
||||
permissions: ["external-admin-user:status"]
|
||||
},
|
||||
updateFirstRechargeRewardConfig: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateFirstRechargeRewardConfig,
|
||||
|
||||
242
src/shared/api/generated/schema.d.ts
vendored
242
src/shared/api/generated/schema.d.ts
vendored
@ -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?: {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user