完善功能

This commit is contained in:
ZuoZuo 2026-05-02 13:02:57 +08:00
parent db964d7471
commit f58c5d2e43
260 changed files with 26381 additions and 4833 deletions

View File

@ -24,24 +24,28 @@
- UI 组件库MUI。
- 图表库ECharts。
- 包管理器pnpm。
- 页面:`src/pages/OverviewPage.jsx``src/pages/ServicesPage.jsx``src/pages/UserManagementPage.jsx`。
- 应用壳:`src/App.jsx`,只做状态编排、懒加载页面、抽屉挂载和事件分发
- 页面加载占位:`src/components/layout/PageSkeleton.jsx`。
- 顶部搜索弹窗:`src/components/search/SearchDialog.jsx`。
- 页面:按业务放在 `src/features/*/pages/`。
- 应用壳:`src/app/App.jsx`、`src/app/layout/AdminLayout.jsx``src/app/router/routeConfig.ts`
- 页面加载占位:`src/shared/ui/PageSkeleton.jsx`。
- 顶部搜索弹窗:`src/features/search/components/SearchDialog.jsx`。
- MUI 主题:`src/theme.js`
- CSS token`src/styles/tokens.css`
- 主要样式和动画:`src/styles/app.css`
- 样式入口:`src/styles/app.css`,实际样式按 `src/styles/*.css``src/features/*/*.css``*.module.css` 拆分。
- 服务端状态:使用 `@tanstack/react-query`,共享入口在 `src/shared/query/``src/shared/hooks/useAdminQuery.js`
- 接口契约:`pnpm gen:api` 会先从 `../hyapp-server/server/admin` 路由同步 `contracts/admin-openapi.json`,再生成 `src/shared/api/generated/schema.d.ts``src/shared/api/generated/endpoints.ts`
## 目录规范
- `src/components/base/`:项目基础组件封装,内部可以使用 MUI。
- `src/components/layout/`:布局组件。
- `src/components/charts/`ECharts 封装和图表组件。
- `src/components/service/`:服务管理相关业务组件。
- `src/pages/`:页面级模块。
- `src/constants/`:导航、状态等常量。
- `src/data/`:演示数据。
- `src/utils/`:工具函数。
- `src/app/`应用壳、Provider、鉴权上下文、路由守卫、统一路由配置和布局。
- `src/shared/`:跨业务共享的 API 基础层、UI 组件、hooks、工具和图表封装。
- `src/features/<module>/`:业务模块,内部放 `api.ts``routes.js``permissions.js``schema.ts``hooks/``components/``pages/`
- 新增后台业务模块必须优先落在 `src/features/`,不要重新创建横向 `pages/``api/``components/` 目录。
- 新增模块优先复制 `admin-module.example.json`,改成业务配置后运行 `pnpm gen:module <config.json>`;它会生成 feature、权限常量、路由接入、OpenAPI 契约和后端 seed/routes 片段。
- 只需要预览生成内容时运行 `pnpm gen:module <config.json> --dry-run`
- `pnpm scaffold:feature <feature-name>` 是低层级骨架命令,只有在不需要权限/菜单/契约自动接入时使用。
- 后端路由或权限变更后先运行 `pnpm gen:api`,只需要同步契约时运行 `pnpm sync:contract`
- 前端 API 路径必须从 `src/shared/api/generated/endpoints.ts` 取,不要在 feature API 中重新手写路径。
- 表单提交前必须走模块内 `schema.ts` 或共享 schema 校验。
## 按需导入规则
@ -64,7 +68,7 @@ import { Button } from "@mui/material";
import { Refresh } from "@mui/icons-material";
```
- ECharts 只在 `src/components/charts/` 内封装和加载,不要在页面里直接散落图表初始化逻辑。
- ECharts 只在 `src/shared/charts/` 内封装和加载,不要在页面里直接散落图表初始化逻辑。
- 页面和抽屉继续使用 `React.lazy` 做拆分加载。
- 顶部搜索弹窗继续使用 lazy 加载,不要静态塞进主入口。
@ -80,11 +84,27 @@ import { Refresh } from "@mui/icons-material";
- 不添加卡片套卡片。
- 不添加无业务意义的说明区、营销区、空泛介绍区。
## 后台表格交互规则
- 表格空态只显示 `当前无数据`,创建入口放在页面工具栏,不放在空态里。
- 表格里的启用/禁用状态优先使用行内 Switch 快捷操作,不使用单独的启用/禁用图标按钮。
- 表格状态筛选使用 Select 下拉框,默认值为 `全部状态`,不使用状态 chip 组。
- 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。
- 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。
- 图片上传表单必须提供统一预览;`webp` 使用浏览器原生动效预览,`svga` 使用 SVGA 播放器预览,`pag` 使用 libpag + wasm 预览。
- BD Leader 创建弹窗必须使用区域下拉选择 `regionId`;目标用户原区域可以不同,创建成功后后端会把目标用户当前区域同步到所选区域。
- 区域创建和区域启用/禁用调用后台管理后端接口,不要求 user-service 新增对应 RPC。
- 国家创建和国家启用/禁用调用后台管理后端接口,不要求 user-service 新增对应 RPC。
## 验证要求
每次完成前端改动后至少运行:
```bash
pnpm check:contracts
pnpm typecheck
pnpm lint
pnpm test
pnpm build
```

View File

@ -7,7 +7,7 @@
- 写代码不要猜不确定的依赖、API、文件结构、现有约定先查本地项目或依赖实际导出。
- 写前端或客户端项目时,不添加无意义的描述文案。
- 使用 `pnpm`
- 页面包含:`总览``服务管理` 和 `用户管理`
- 页面包含:`总览``用户管理`、`房间管理``地区管理``团队管理` 等后台模块
- 所有组件都要模块化,包括基础组件、布局组件、业务组件、图表组件。
- 基础 UI 使用 MUI但整体风格必须统一为白色后台管理平台风格。
- 修改 MUI 组件颜色和样式时注意兼容性,优先通过主题和封装组件统一处理。

25
admin-module.example.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "orders",
"singularName": "order",
"label": "订单管理",
"path": "/orders",
"menuCode": "orders",
"menuKey": "orders",
"permissionPrefix": "order",
"icon": "receipt",
"sort": 80,
"permissions": [
{ "action": "view", "name": "订单查看", "kind": "menu" },
{ "action": "create", "name": "订单创建", "kind": "button" },
{ "action": "update", "name": "订单更新", "kind": "button" },
{ "action": "delete", "name": "订单删除", "kind": "button" },
{ "action": "export", "name": "订单导出", "kind": "button" }
],
"apiRoutes": [
{ "method": "GET", "path": "/orders", "action": "view", "kind": "list", "operationId": "listOrders", "handler": "ListOrders" },
{ "method": "POST", "path": "/orders", "action": "create", "operationId": "createOrder", "handler": "CreateOrder" },
{ "method": "PATCH", "path": "/orders/{id}", "action": "update", "operationId": "updateOrder", "handler": "UpdateOrder" },
{ "method": "DELETE", "path": "/orders/{id}", "action": "delete", "operationId": "deleteOrder", "handler": "DeleteOrder" },
{ "method": "GET", "path": "/orders/export", "action": "export", "operationId": "exportOrders", "handler": "ExportOrders", "raw": true }
]
}

3191
contracts/admin-openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -23,10 +23,17 @@ export default [
rules: {
...reactHooks.configs.recommended.rules,
"no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
"react-hooks/exhaustive-deps": "off",
"react-hooks/set-state-in-effect": "off",
"react-hooks/use-memo": "off",
"react-refresh/only-export-components": "off"
}
},
{
files: ["scripts/**/*.{js,mjs}"],
languageOptions: {
ecmaVersion: "latest",
globals: globals.node,
sourceType: "module"
}
}
];

View File

@ -3,13 +3,19 @@
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@9.15.9",
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"check:contracts": "tsx scripts/validate-admin-architecture.ts",
"gen:api": "node scripts/sync-openapi-from-backend.mjs && openapi-typescript contracts/admin-openapi.json -o src/shared/api/generated/schema.d.ts && node scripts/generate-api-client.mjs",
"gen:module": "node scripts/generate-admin-module.mjs",
"lint": "eslint .",
"format": "prettier --write .",
"scaffold:feature": "node scripts/scaffold-feature.mjs",
"sync:contract": "node scripts/sync-openapi-from-backend.mjs",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
@ -17,22 +23,33 @@
"@emotion/styled": "11.14.1",
"@mui/icons-material": "9.0.0",
"@mui/material": "9.0.0",
"@tanstack/react-query": "^5.100.6",
"echarts": "6.0.0",
"libpag": "4.5.52",
"react": "19.2.5",
"react-dom": "19.2.5",
"react-router-dom": "^7.14.2"
"react-router-dom": "^7.14.2",
"svgaplayerweb": "2.3.2",
"zod": "^4.4.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"jsdom": "^29.1.1",
"openapi-typescript": "^7.13.0",
"prettier": "^3.8.3",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "8.0.10",
"vitest": "^4.1.5"
}

1146
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
public/vendor/libpag.wasm vendored Executable file

Binary file not shown.

View File

@ -0,0 +1,595 @@
import { existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const args = process.argv.slice(2);
const configArg = args.find((arg) => !arg.startsWith("--"));
const dryRun = args.includes("--dry-run");
const skipApiGenerate = args.includes("--skip-api-generate");
if (!configArg) {
console.error("Usage: pnpm gen:module <admin-module.json> [--dry-run] [--skip-api-generate]");
process.exit(1);
}
const configPath = path.resolve(process.cwd(), configArg);
const rawConfig = JSON.parse(await readFile(configPath, "utf8"));
const moduleConfig = normalizeConfig(rawConfig);
const plannedWrites = [];
queueFileWrites(moduleConfig);
await updatePermissions(moduleConfig);
await updateRouteConfig(moduleConfig);
await updateOpenApiContract(moduleConfig);
if (dryRun) {
console.log(`Dry run: ${plannedWrites.length} file changes planned.`);
for (const write of plannedWrites) {
console.log(`${write.exists ? "Update" : "Create"} ${path.relative(rootDir, write.filePath)}`);
}
process.exit(0);
}
for (const write of plannedWrites) {
await mkdir(path.dirname(write.filePath), { recursive: true });
if (!write.overwrite && existsSync(write.filePath)) {
console.log(`Skipped existing ${path.relative(rootDir, write.filePath)}`);
continue;
}
await writeFile(write.filePath, write.content);
console.log(`${write.exists ? "Updated" : "Created"} ${path.relative(rootDir, write.filePath)}`);
}
if (!skipApiGenerate) {
run("pnpm", ["exec", "openapi-typescript", "contracts/admin-openapi.json", "-o", "src/shared/api/generated/schema.d.ts"]);
run(process.execPath, ["scripts/generate-api-client.mjs"]);
}
console.log(`Module ${moduleConfig.name} generated. Run pnpm check:contracts after backend routes are implemented.`);
function normalizeConfig(config) {
const name = toKebabCase(required(config.name, "name"));
const camelName = toCamelCase(name);
const pascalName = toPascalCase(name);
const singularName = toPascalCase(config.singularName || singularize(name));
const singularCamel = lowerFirst(singularName);
const label = required(config.label, "label");
const routePath = config.path || `/${name}`;
const menuCode = config.menuCode || name;
const permissionPrefix = config.permissionPrefix || singularize(name);
const pageKey = config.pageKey || name;
const menuKey = config.menuKey || toCamelCase(menuCode);
const permissions = normalizePermissions(config.permissions || config.actions || defaultActions(), {
label,
permissionPrefix
});
const viewPermission = permissions.find((permission) => permission.action === "view") || permissions[0];
if (!viewPermission) {
throw new Error("At least one permission is required");
}
const apiRoutes = normalizeApiRoutes(config.apiRoutes || defaultApiRoutes(routePath, viewPermission.code), {
name,
permissions,
routePath,
singularName
});
return {
apiRoutes,
camelName,
icon: config.icon || "menu",
label,
menuCode,
menuKey,
name,
pageKey,
parentMenuCode: config.parentMenuCode || "",
pascalName,
permissionPrefix,
permissions,
routePath,
singularCamel,
singularName,
sort: Number(config.sort || 100),
viewPermission
};
}
function queueFileWrites(config) {
const featureDir = path.join(rootDir, "src/features", config.name);
const files = new Map([
["api.ts", renderApi(config)],
["routes.js", renderRoutes(config)],
["permissions.js", renderFeaturePermissions(config)],
["schema.ts", renderSchema(config)],
[`hooks/use${config.pascalName}Page.js`, renderPageHook(config)],
[`pages/${config.pascalName}Page.jsx`, renderPage(config)],
[`${config.name}.module.css`, renderCss()],
["components/.gitkeep", ""]
]);
for (const [relativePath, content] of files) {
const filePath = path.join(featureDir, relativePath);
planWrite(filePath, content, { overwrite: false });
}
const generatedDir = path.join(rootDir, "generated/admin-modules", config.name);
planWrite(path.join(generatedDir, "backend-seed-snippet.go.txt"), renderBackendSeedSnippet(config), { overwrite: true });
planWrite(path.join(generatedDir, "backend-routes-snippet.go.txt"), renderBackendRoutesSnippet(config), { overwrite: true });
planWrite(path.join(generatedDir, "permissions.md"), renderPermissionChecklist(config), { overwrite: true });
}
async function updatePermissions(config) {
const filePath = path.join(rootDir, "src/app/permissions.ts");
let source = await readFile(filePath, "utf8");
const permissionEntries = config.permissions.map((permission) => ({
key: permission.key,
value: permission.code
}));
const menuEntry = { key: config.menuKey, value: config.menuCode };
source = insertObjectEntries(source, "PERMISSIONS", permissionEntries);
source = insertObjectEntries(source, "MENU_CODES", [menuEntry]);
planWrite(filePath, source, { overwrite: true });
}
async function updateRouteConfig(config) {
const filePath = path.join(rootDir, "src/app/router/routeConfig.ts");
let source = await readFile(filePath, "utf8");
const importLine = `import { ${config.camelName}Routes } from "@/features/${config.name}/routes.js";`;
const routeSpread = ` ...${config.camelName}Routes,`;
if (!source.includes(importLine)) {
source = source.replace(/(import type \{ MenuCode \} from "@\/app\/permissions";)/, `${importLine}\n$1`);
}
if (!source.includes(routeSpread)) {
const routesStart = source.indexOf("export const adminRoutes: AdminRoute[] = [");
const routesEnd = source.indexOf("\n];", routesStart);
if (routesStart < 0 || routesEnd < 0) {
throw new Error("Cannot find adminRoutes block in routeConfig.ts");
}
const beforeRoutesEnd = source.slice(0, routesEnd);
const afterRoutesEnd = source.slice(routesEnd);
const separator = beforeRoutesEnd.trimEnd().endsWith(",") ? "\n" : ",\n";
source = `${beforeRoutesEnd}${separator}${routeSpread}${afterRoutesEnd}`;
}
planWrite(filePath, source, { overwrite: true });
}
async function updateOpenApiContract(config) {
const filePath = path.join(rootDir, "contracts/admin-openapi.json");
const contract = JSON.parse(await readFile(filePath, "utf8"));
contract.paths = contract.paths || {};
for (const route of config.apiRoutes) {
const openApiPath = toOpenApiPath(route.path);
contract.paths[openApiPath] = contract.paths[openApiPath] || {};
contract.paths[openApiPath][route.method.toLowerCase()] = {
operationId: route.operationId,
...(route.permission
? {
"x-permission": route.permission,
"x-permissions": [route.permission]
}
: {}),
...(pathParams(openApiPath).length
? {
parameters: pathParams(openApiPath).map((name) =>
name === "id"
? { "$ref": "#/components/parameters/Id" }
: { in: "path", name, required: true, schema: { type: "integer" } }
)
}
: {}),
responses: { "200": { "$ref": "#/components/responses/EmptyResponse" } }
};
}
contract.paths = sortObject(contract.paths, (pathItem) => sortObject(pathItem, undefined, ["get", "post", "put", "patch", "delete"]));
planWrite(filePath, `${JSON.stringify(contract, null, 2)}\n`, { overwrite: true });
}
function renderApi(config) {
const imports = new Set(["apiRequest"]);
const typeImports = new Set();
const functions = config.apiRoutes.map((route) => renderApiFunction(route, config, typeImports));
const needsEndpointObject = config.apiRoutes.some((route) => route.method !== "GET" || route.raw);
const endpointImports = needsEndpointObject ? "API_ENDPOINTS, API_OPERATIONS, apiEndpointPath" : "API_OPERATIONS, apiEndpointPath";
return `import { ${[...imports].join(", ")} } from "@/shared/api/request";
import { ${endpointImports} } from "@/shared/api/generated/endpoints";
${typeImports.size ? `import type { ${[...typeImports].sort().join(", ")} } from "@/shared/api/types";\n` : ""}
type ${config.singularName}Record = Record<string, unknown>;
${functions.join("\n\n")}
`;
}
function renderApiFunction(route, config, typeImports) {
const operationKey = `API_OPERATIONS.${route.operationId}`;
const endpointLine = ` const endpoint = API_ENDPOINTS.${route.operationId};`;
if (route.raw) {
typeImports.add("PageQuery");
return `export function ${route.operationId}(query?: PageQuery): Promise<Response> {
return apiRequest(apiEndpointPath(${operationKey}), { query, raw: true });
}`;
}
if (route.kind === "list") {
typeImports.add("ApiPage");
typeImports.add("PageQuery");
return `export function ${route.operationId}(query?: PageQuery): Promise<ApiPage<${config.singularName}Record>> {
return apiRequest<ApiPage<${config.singularName}Record>>(apiEndpointPath(${operationKey}), { query });
}`;
}
if (route.hasId) {
typeImports.add("EntityId");
}
if (route.method === "DELETE") {
return `export function ${route.operationId}(id: EntityId): Promise<unknown> {
${endpointLine}
return apiRequest(apiEndpointPath(${operationKey}, { id }), { method: endpoint.method });
}`;
}
if (route.method === "GET") {
const params = route.hasId ? "id: EntityId" : "";
const pathParams = route.hasId ? ", { id }" : "";
return `export function ${route.operationId}(${params}): Promise<${config.singularName}Record> {
return apiRequest<${config.singularName}Record>(apiEndpointPath(${operationKey}${pathParams}));
}`;
}
const params = route.hasId ? "id: EntityId, payload: Record<string, unknown>" : "payload: Record<string, unknown>";
const pathParams = route.hasId ? ", { id }" : "";
return `export function ${route.operationId}(${params}): Promise<${config.singularName}Record> {
${endpointLine}
return apiRequest<${config.singularName}Record, Record<string, unknown>>(apiEndpointPath(${operationKey}${pathParams}), {
body: payload,
method: endpoint.method
});
}`;
}
function renderRoutes(config) {
return `import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const ${config.camelName}Routes = [
{
label: ${JSON.stringify(config.label)},
loader: () => import("./pages/${config.pascalName}Page.jsx").then((module) => module.${config.pascalName}Page),
menuCode: MENU_CODES.${config.menuKey},
pageKey: ${JSON.stringify(config.pageKey)},
path: ${JSON.stringify(config.routePath)},
permission: PERMISSIONS.${config.viewPermission.key}
}
];
`;
}
function renderFeaturePermissions(config) {
const entries = config.permissions
.map((permission) => ` ${abilityName(permission.action)}: can(PERMISSIONS.${permission.key})`)
.join(",\n");
return `import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function use${config.singularName}Abilities() {
const { can } = useAuth();
return {
${entries}
};
}
`;
}
function renderSchema(config) {
return `import { z } from "zod";
export const ${config.singularCamel}FormSchema = z.record(z.string(), z.unknown());
export type ${config.singularName}Form = z.infer<typeof ${config.singularCamel}FormSchema>;
`;
}
function renderPageHook(config) {
const listRoute = config.apiRoutes.find((route) => route.kind === "list") || config.apiRoutes[0];
return `import { useCallback } from "react";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { ${listRoute.operationId} } from "@/features/${config.name}/api";
import { use${config.singularName}Abilities } from "@/features/${config.name}/permissions.js";
const emptyData = { items: [], total: 0, page: 1, pageSize: 10 };
export function use${config.pascalName}Page() {
const abilities = use${config.singularName}Abilities();
const queryFn = useCallback(() => ${listRoute.operationId}({ page: 1, page_size: 10 }), []);
const { data, error, loading, reload } = useAdminQuery(queryFn, {
errorMessage: "加载失败",
initialData: emptyData,
queryKey: [${JSON.stringify(config.name)}]
});
return { abilities, data, error, loading, reload };
}
`;
}
function renderPage(config) {
return `import { DataState } from "@/shared/ui/DataState.jsx";
import { use${config.pascalName}Page } from "@/features/${config.name}/hooks/use${config.pascalName}Page.js";
import styles from "@/features/${config.name}/${config.name}.module.css";
export function ${config.pascalName}Page() {
const page = use${config.pascalName}Page();
return (
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<section className={styles.root} />
</DataState>
);
}
`;
}
function renderCss() {
return `.root {
min-height: 100%;
}
`;
}
function renderBackendSeedSnippet(config) {
const permissions = config.permissions
.map((permission) => `\t{Name: "${permission.name}", Code: "${permission.code}", Kind: "${permission.kind}"},`)
.join("\n");
return `// Add to hyapp-server/server/admin/internal/repository/seed.go defaultPermissions.
${permissions}
// Add to defaultMenus.
{Title: "${config.label}", Code: "${config.menuCode}", Path: "${config.routePath}", Icon: "${config.icon}", PermissionCode: "${config.viewPermission.code}", Sort: ${config.sort}, Visible: true},
`;
}
function renderBackendRoutesSnippet(config) {
const lines = config.apiRoutes.map((route) => {
const permissionGuard = route.permission
? `, middleware.RequirePermission("${route.permission}")`
: "";
return `protected.${route.method}("${toGinPath(route.path)}"${permissionGuard}, h.${route.handler})`;
});
return `// Add to the module RegisterRoutes function.
${lines.join("\n")}
`;
}
function renderPermissionChecklist(config) {
const rows = config.permissions
.map((permission) => `| \`${permission.code}\` | ${permission.kind} | ${permission.name} |`)
.join("\n");
const routeRows = config.apiRoutes
.map((route) => `| ${route.method} | \`${route.path}\` | \`${route.permission || "登录态"}\` | ${route.handler} |`)
.join("\n");
return `# ${config.label}
## 权限
| Code | Kind | Name |
| --- | --- | --- |
${rows}
## 后端路由
| Method | Path | Permission | Handler |
| --- | --- | --- | --- |
${routeRows}
`;
}
function normalizePermissions(value, context) {
return value.map((item) => {
const permission = typeof item === "string" ? { action: item } : item;
const action = permission.action || permission.key;
const code = permission.code || `${context.permissionPrefix}:${action}`;
const key = permission.permissionKey || toPermissionKey(code);
return {
action,
code,
key,
kind: permission.kind || (action === "view" ? "menu" : "button"),
name: permission.name || `${context.label}${actionLabel(action)}`
};
});
}
function normalizeApiRoutes(value, context) {
const permissionByAction = new Map(context.permissions.map((permission) => [permission.action, permission.code]));
return value.map((route) => {
const method = (route.method || "GET").toUpperCase();
const routePath = route.path || context.routePath;
const permission = route.permission || permissionByAction.get(route.action || "view") || "";
const operationId = route.operationId || inferOperationId({ method, routePath, route, context });
return {
handler: route.handler || toPascalCase(operationId),
hasId: routePath.includes("{id}") || routePath.includes(":id"),
kind: route.kind || inferRouteKind(method, routePath),
method,
operationId,
path: toOpenApiPath(routePath),
permission,
raw: Boolean(route.raw) || routePath.includes("export")
};
});
}
function defaultActions() {
return ["view", "create", "update", "delete", "export"];
}
function defaultApiRoutes(routePath, viewPermission) {
return [{ action: "view", kind: "list", method: "GET", path: routePath, permission: viewPermission }];
}
function inferOperationId({ method, routePath, route, context }) {
if (route.action === "export" || routePath.includes("export")) {
return `export${context.pascalName}`;
}
if (route.kind === "list" || (method === "GET" && !routePath.includes("{id}") && !routePath.includes(":id"))) {
return `list${context.pascalName}`;
}
if (method === "GET") {
return `get${context.singularName}`;
}
if (method === "POST") {
return routePath.includes(":id") || routePath.includes("{id}") ? `${route.action || "run"}${context.singularName}` : `create${context.singularName}`;
}
if (method === "PATCH" || method === "PUT") {
return `update${context.singularName}`;
}
if (method === "DELETE") {
return `delete${context.singularName}`;
}
return `${method.toLowerCase()}${context.singularName}`;
}
function inferRouteKind(method, routePath) {
if (method === "GET" && !routePath.includes("{id}") && !routePath.includes(":id")) {
return "list";
}
return "item";
}
function insertObjectEntries(source, objectName, entries) {
const objectStart = source.indexOf(`export const ${objectName} = {`);
if (objectStart < 0) {
throw new Error(`Cannot find ${objectName} in permissions.ts`);
}
const objectEnd = source.indexOf("\n} as const;", objectStart);
if (objectEnd < 0) {
throw new Error(`Cannot find ${objectName} closing in permissions.ts`);
}
const before = source.slice(0, objectEnd);
const after = source.slice(objectEnd);
const existingBlock = source.slice(objectStart, objectEnd);
const additions = entries
.filter((entry) => !existingBlock.includes(`${entry.key}:`) && !existingBlock.includes(`"${entry.value}"`))
.map((entry) => ` ${entry.key}: "${entry.value}"`);
if (!additions.length) {
return source;
}
const separator = before.trimEnd().endsWith(",") ? "\n" : ",\n";
return `${before}${separator}${additions.join(",\n")}${after}`;
}
function planWrite(filePath, content, options) {
plannedWrites.push({
content,
exists: existsSync(filePath),
filePath,
overwrite: options.overwrite
});
}
function run(command, commandArgs) {
const result = spawnSync(command, commandArgs, { cwd: rootDir, stdio: "inherit" });
if (result.status !== 0) {
process.exit(result.status || 1);
}
}
function required(value, name) {
if (!value) {
throw new Error(`Missing required config field: ${name}`);
}
return value;
}
function pathParams(routePath) {
return [...routePath.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
}
function toOpenApiPath(routePath) {
return routePath.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
}
function toGinPath(routePath) {
return routePath.replace(/\{([^}]+)\}/g, ":$1");
}
function toKebabCase(value) {
return value
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.toLowerCase();
}
function toCamelCase(value) {
return value.replace(/-([a-z0-9])/g, (_, letter) => letter.toUpperCase());
}
function toPascalCase(value) {
const camel = toCamelCase(toKebabCase(value));
return camel.charAt(0).toUpperCase() + camel.slice(1);
}
function lowerFirst(value) {
return value.charAt(0).toLowerCase() + value.slice(1);
}
function singularize(value) {
return value.endsWith("ies") ? `${value.slice(0, -3)}y` : value.endsWith("s") ? value.slice(0, -1) : value;
}
function toPermissionKey(code) {
return toCamelCase(code.replace(/:/g, "-"));
}
function abilityName(action) {
return `can${toPascalCase(action)}`;
}
function actionLabel(action) {
const labels = {
audit: "审核",
config: "配置",
create: "创建",
delete: "删除",
export: "导出",
import: "导入",
status: "状态变更",
update: "更新",
view: "查看"
};
return labels[action] || action;
}
function sortObject(object, mapValue = (value) => value, preferredOrder = []) {
return Object.fromEntries(
Object.entries(object)
.sort(([left], [right]) => {
const leftIndex = preferredOrder.indexOf(left);
const rightIndex = preferredOrder.indexOf(right);
if (leftIndex >= 0 || rightIndex >= 0) {
return (leftIndex < 0 ? Number.MAX_SAFE_INTEGER : leftIndex) - (rightIndex < 0 ? Number.MAX_SAFE_INTEGER : rightIndex);
}
return left.localeCompare(right);
})
.map(([key, value]) => [key, mapValue(value)])
);
}

View File

@ -0,0 +1,102 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const contractPath = path.join(rootDir, "contracts/admin-openapi.json");
const outputPath = path.join(rootDir, "src/shared/api/generated/endpoints.ts");
const methods = ["get", "post", "put", "patch", "delete"];
const contract = JSON.parse(await readFile(contractPath, "utf8"));
const serverUrl = contract.servers?.[0]?.url || "";
const apiPathPrefix = serverUrl.startsWith("/api") ? serverUrl.slice("/api".length) : serverUrl;
const endpoints = [];
for (const [openapiPath, pathItem] of Object.entries(contract.paths || {})) {
for (const method of methods) {
const operation = pathItem[method];
if (!operation?.operationId) {
continue;
}
const permissions = Array.isArray(operation["x-permissions"])
? operation["x-permissions"]
: operation["x-permission"]
? [operation["x-permission"]]
: [];
endpoints.push({
method: method.toUpperCase(),
operationId: operation.operationId,
path: `${apiPathPrefix}${openapiPath}`,
permission: operation["x-permission"] || permissions[0],
permissions
});
}
}
endpoints.sort((left, right) => left.operationId.localeCompare(right.operationId));
const operationLines = endpoints.map((endpoint) => {
return ` ${endpoint.operationId}: ${JSON.stringify(endpoint.operationId)}`;
});
const endpointLines = endpoints.map((endpoint) => {
const permission = endpoint.permission ? `,\n permission: ${JSON.stringify(endpoint.permission)}` : "";
const permissions = endpoint.permissions.length ? `,\n permissions: ${JSON.stringify(endpoint.permissions)}` : "";
return ` ${endpoint.operationId}: {
method: ${JSON.stringify(endpoint.method)},
operationId: API_OPERATIONS.${endpoint.operationId},
path: ${JSON.stringify(endpoint.path)}${permission}${permissions}
}`;
});
const output = `/* eslint-disable */
// Generated by scripts/generate-api-client.mjs. Do not edit manually.
import type { PermissionCode } from "@/app/permissions";
export type ApiHttpMethod = "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
export interface ApiEndpoint {
method: ApiHttpMethod;
operationId: string;
path: string;
permission?: PermissionCode;
permissions?: PermissionCode[];
}
export const API_OPERATIONS = {
${operationLines.join(",\n")}
} as const;
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
${endpointLines.join(",\n")}
};
export const API_OPERATION_IDS = Object.values(API_OPERATIONS);
export const API_PERMISSION_CODES = Array.from(
new Set(
Object.values(API_ENDPOINTS)
.flatMap((endpoint) => endpoint.permissions || (endpoint.permission ? [endpoint.permission] : []))
.filter((permission): permission is PermissionCode => Boolean(permission))
)
);
export type ApiPathParams = Record<string, number | string>;
export function apiEndpointPath(operationId: ApiOperationId, params: ApiPathParams = {}) {
let endpointPath = API_ENDPOINTS[operationId].path;
for (const [key, value] of Object.entries(params)) {
endpointPath = endpointPath.replace(\`{\${key}}\`, encodeURIComponent(String(value)));
}
return endpointPath;
}
`;
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, output);
console.log(`Generated ${path.relative(rootDir, outputPath)} from ${path.relative(rootDir, contractPath)}`);

View File

@ -0,0 +1,147 @@
import { mkdir, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const args = process.argv.slice(2);
const featureName = args.find((arg) => !arg.startsWith("--"));
if (!featureName) {
console.error("Usage: pnpm scaffold:feature <feature-name> --label=模块名称 --path=/path --menu-code=menu-code --permission=module:view");
process.exit(1);
}
const options = Object.fromEntries(
args
.filter((arg) => arg.startsWith("--"))
.map((arg) => {
const [key, ...valueParts] = arg.slice(2).split("=");
return [key, valueParts.join("=")];
})
);
const kebabName = toKebabCase(featureName);
const camelName = toCamelCase(kebabName);
const pascalName = toPascalCase(kebabName);
const routePath = options.path || `/${kebabName}`;
const label = options.label || pascalName;
const menuCode = options["menu-code"] || kebabName;
const permission = options.permission || `${kebabName}:view`;
const featureDir = path.join(rootDir, "src/features", kebabName);
const files = new Map([
[
"api.ts",
`import { apiRequest } from "@/shared/api/request";
export function list${pascalName}(query?: Record<string, string | number>) {
return apiRequest("${routePath}", { query });
}
`
],
[
"routes.js",
`export const ${camelName}Routes = [
{
label: ${JSON.stringify(label)},
loader: () => import("./pages/${pascalName}Page.jsx").then((module) => module.${pascalName}Page),
menuCode: ${JSON.stringify(menuCode)},
pageKey: ${JSON.stringify(kebabName)},
path: ${JSON.stringify(routePath)},
permission: ${JSON.stringify(permission)}
}
];
`
],
[
"permissions.js",
`import { useAuth } from "@/app/auth/AuthProvider.jsx";
export function use${pascalName}Abilities() {
const { can } = useAuth();
return {
canView: can(${JSON.stringify(permission)})
};
}
`
],
[
`hooks/use${pascalName}Page.js`,
`import { useCallback } from "react";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { list${pascalName} } from "@/features/${kebabName}/api";
import { use${pascalName}Abilities } from "@/features/${kebabName}/permissions.js";
const emptyData = { items: [], total: 0, page: 1, pageSize: 10 };
export function use${pascalName}Page() {
const abilities = use${pascalName}Abilities();
const queryFn = useCallback(() => list${pascalName}({ page: 1, page_size: 10 }), []);
const { data, error, loading, reload } = useAdminQuery(queryFn, {
errorMessage: "加载失败",
initialData: emptyData,
queryKey: [${JSON.stringify(kebabName)}]
});
return { abilities, data, error, loading, reload };
}
`
],
[
`pages/${pascalName}Page.jsx`,
`import { DataState } from "@/shared/ui/DataState.jsx";
import { use${pascalName}Page } from "@/features/${kebabName}/hooks/use${pascalName}Page.js";
import styles from "@/features/${kebabName}/${kebabName}.module.css";
export function ${pascalName}Page() {
const page = use${pascalName}Page();
return (
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<section className={styles.root} />
</DataState>
);
}
`
],
[
`${kebabName}.module.css`,
`.root {
min-height: 100%;
}
`
],
["components/.gitkeep", ""]
]);
await mkdir(featureDir, { recursive: true });
for (const [relativePath, content] of files) {
const absolutePath = path.join(featureDir, relativePath);
await mkdir(path.dirname(absolutePath), { recursive: true });
if (existsSync(absolutePath)) {
console.log(`Skipped existing ${path.relative(rootDir, absolutePath)}`);
continue;
}
await writeFile(absolutePath, content);
console.log(`Created ${path.relative(rootDir, absolutePath)}`);
}
function toKebabCase(value) {
return value
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.toLowerCase();
}
function toCamelCase(value) {
return value.replace(/-([a-z0-9])/g, (_, letter) => letter.toUpperCase());
}
function toPascalCase(value) {
const camel = toCamelCase(value);
return camel.charAt(0).toUpperCase() + camel.slice(1);
}

View File

@ -0,0 +1,151 @@
import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultBackendDir = path.resolve(rootDir, "../hyapp-server/server/admin");
const backendDir = process.env.HYAPP_ADMIN_SERVER_DIR
? path.resolve(process.env.HYAPP_ADMIN_SERVER_DIR)
: defaultBackendDir;
const contractPath = path.join(rootDir, "contracts/admin-openapi.json");
const methods = ["get", "post", "put", "patch", "delete"];
const contract = JSON.parse(await readFile(contractPath, "utf8"));
const routes = await readBackendRoutes(path.join(backendDir, "internal/modules"));
for (const route of routes) {
contract.paths[route.path] = contract.paths[route.path] || {};
const operation = contract.paths[route.path][route.method] || {};
operation.operationId = route.operationId;
operation.responses = operation.responses || { "200": { "$ref": "#/components/responses/EmptyResponse" } };
const pathParams = [...route.path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
if (pathParams.length && !operation.parameters) {
operation.parameters = pathParams.map((name) =>
name === "id"
? { "$ref": "#/components/parameters/Id" }
: {
in: "path",
name,
required: true,
schema: { type: "integer" }
}
);
}
if (route.permissions.length) {
operation["x-permission"] = operation["x-permission"] || route.permissions[0];
operation["x-permissions"] = route.permissions;
}
contract.paths[route.path][route.method] = operation;
}
contract.paths = sortObject(contract.paths, (pathItem) => sortObject(pathItem, undefined, methods));
await writeFile(contractPath, `${JSON.stringify(contract, null, 2)}\n`);
console.log(`Synced ${routes.length} backend routes into ${path.relative(rootDir, contractPath)}`);
async function readBackendRoutes(modulesDir) {
const modules = await readdir(modulesDir, { withFileTypes: true });
const routes = [];
for (const moduleEntry of modules.filter((entry) => entry.isDirectory())) {
const moduleDir = path.join(modulesDir, moduleEntry.name);
const files = await readdir(moduleDir, { withFileTypes: true });
for (const fileEntry of files.filter((entry) => entry.isFile() && entry.name.endsWith(".go"))) {
const source = await readFile(path.join(moduleDir, fileEntry.name), "utf8");
if (!source.includes("RegisterRoutes")) {
continue;
}
routes.push(...parseRoutes(source, moduleEntry.name));
}
}
return routes.sort((left, right) => `${left.path}:${left.method}`.localeCompare(`${right.path}:${right.method}`));
}
function parseRoutes(source, moduleName) {
const groupPrefixes = new Map([
["api", ""],
["protected", ""]
]);
const routes = [];
for (const line of source.split("\n")) {
const groupMatch = line.match(/^\s*(\w+)\s*:=\s*(\w+)\.Group\("([^"]+)"\)/);
if (groupMatch) {
const [, groupName, parentName, prefix] = groupMatch;
groupPrefixes.set(groupName, `${groupPrefixes.get(parentName) || ""}${prefix}`);
continue;
}
const routeMatch = line.match(/^\s*(\w+)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"(?:,\s*(.*))?\)/);
if (!routeMatch) {
continue;
}
const [, groupName, method, routePath, args = ""] = routeMatch;
const handlerMatch = args.match(/h\.([A-Za-z0-9_]+)/g);
const handlerName = handlerMatch?.at(-1)?.replace("h.", "") || `${method}${routePath}`;
const permissions = readPermissions(args);
routes.push({
method: method.toLowerCase(),
operationId: operationIdForHandler(moduleName, handlerName, routePath, method),
path: toOpenApiPath(`${groupPrefixes.get(groupName) || ""}${routePath}`),
permissions
});
}
return routes;
}
function readPermissions(args) {
const anyMatch = args.match(/RequireAnyPermission\(([^)]*)\)/);
if (anyMatch) {
return [...anyMatch[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
}
const match = args.match(/RequirePermission\("([^"]+)"\)/);
return match ? [match[1]] : [];
}
function toOpenApiPath(routePath) {
return routePath.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
}
function lowerFirst(value) {
return value.charAt(0).toLowerCase() + value.slice(1);
}
function operationIdForHandler(moduleName, handlerName, routePath, method) {
if (moduleName === "appuser") {
return `app${handlerName}`;
}
if (moduleName === "countryregion" && handlerName === "DisableCountry" && method === "DELETE") {
return "deleteCountry";
}
if (moduleName === "hostorg" && handlerName === "SetBDStatus" && routePath.includes("bd-leaders")) {
return "setBDLeaderStatus";
}
if (moduleName === "resource" && handlerName === "UpdateResourceGroup" && routePath.includes("/items")) {
return "updateResourceGroupItems";
}
return lowerFirst(handlerName);
}
function sortObject(object, mapValue = (value) => value, preferredOrder = []) {
return Object.fromEntries(
Object.entries(object)
.sort(([left], [right]) => {
const leftIndex = preferredOrder.indexOf(left);
const rightIndex = preferredOrder.indexOf(right);
if (leftIndex >= 0 || rightIndex >= 0) {
return (leftIndex < 0 ? Number.MAX_SAFE_INTEGER : leftIndex) - (rightIndex < 0 ? Number.MAX_SAFE_INTEGER : rightIndex);
}
return left.localeCompare(right);
})
.map(([key, value]) => [key, mapValue(value)])
);
}

View File

@ -0,0 +1,129 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { MENU_CODE_VALUES, PERMISSION_CODES } from "../src/app/permissions";
import { fallbackNavigation } from "../src/app/navigation/menu";
import { adminRoutes } from "../src/app/router/routeConfig";
type OpenApiOperation = {
operationId?: string;
"x-permission"?: string;
"x-permissions"?: string[];
};
type OpenApiDocument = {
paths?: Record<string, Record<string, OpenApiOperation>>;
};
type NavItem = {
children?: NavItem[];
code?: string;
path?: string;
permissionCode?: string;
};
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const contract = JSON.parse(readFileSync(path.join(rootDir, "contracts/admin-openapi.json"), "utf8")) as OpenApiDocument;
const errors: string[] = [];
const knownPermissions = new Set<string>(PERMISSION_CODES);
const knownMenuCodes = new Set<string>(MENU_CODE_VALUES);
const routesByPath = new Map<string, (typeof adminRoutes)[number]>();
const routesByMenuCode = new Map<string, (typeof adminRoutes)[number]>();
const routePermissions = new Set<string>();
for (const route of adminRoutes) {
if (!route.path) {
errors.push(`Route ${route.pageKey} is missing path`);
}
if (!route.menuCode) {
errors.push(`Route ${route.path} is missing menuCode`);
}
if (!route.permission) {
errors.push(`Route ${route.path} is missing permission`);
}
if (route.menuCode && !knownMenuCodes.has(route.menuCode)) {
errors.push(`Route ${route.path} uses unknown menuCode: ${route.menuCode}`);
}
if (route.permission && !knownPermissions.has(route.permission)) {
errors.push(`Route ${route.path} uses unknown permission: ${route.permission}`);
}
if (routesByPath.has(route.path)) {
errors.push(`Duplicate route path: ${route.path}`);
}
if (routesByMenuCode.has(route.menuCode)) {
errors.push(`Duplicate route menuCode: ${route.menuCode}`);
}
routesByPath.set(route.path, route);
routesByMenuCode.set(route.menuCode, route);
routePermissions.add(route.permission);
}
for (const item of flattenNavItems(fallbackNavigation as NavItem[])) {
if (item.code && !knownMenuCodes.has(item.code)) {
errors.push(`Fallback menu uses unknown menu code: ${item.code}`);
}
if (!item.path) {
continue;
}
const route = item.code ? routesByMenuCode.get(item.code) : undefined;
if (!route) {
errors.push(`Fallback menu ${item.code || item.path} has no matching route`);
continue;
}
if (route.path !== item.path) {
errors.push(`Fallback menu ${item.code} path mismatch: ${item.path} !== ${route.path}`);
}
if (item.permissionCode !== route.permission) {
errors.push(`Fallback menu ${item.code} permission mismatch: ${item.permissionCode} !== ${route.permission}`);
}
}
const operationIds = new Set<string>();
const apiPermissions = new Set<string>();
const httpMethods = new Set(["delete", "get", "patch", "post", "put"]);
for (const [endpointPath, pathItem] of Object.entries(contract.paths || {})) {
for (const [method, operation] of Object.entries(pathItem)) {
if (!httpMethods.has(method)) {
continue;
}
if (!operation.operationId) {
errors.push(`${method.toUpperCase()} ${endpointPath} is missing operationId`);
} else if (operationIds.has(operation.operationId)) {
errors.push(`Duplicate OpenAPI operationId: ${operation.operationId}`);
} else {
operationIds.add(operation.operationId);
}
const permissions = operation["x-permissions"]?.length
? operation["x-permissions"]
: operation["x-permission"]
? [operation["x-permission"]]
: [];
for (const permission of permissions) {
apiPermissions.add(permission);
if (!knownPermissions.has(permission)) {
errors.push(`${method.toUpperCase()} ${endpointPath} uses unknown x-permission: ${permission}`);
}
}
}
}
if (errors.length) {
console.error("Admin architecture validation failed:");
for (const error of errors) {
console.error(`- ${error}`);
}
process.exit(1);
}
console.log(
`Admin architecture validation passed: ${adminRoutes.length} routes, ${operationIds.size} operations, ${apiPermissions.size} protected permissions.`
);
function flattenNavItems(items: NavItem[]): NavItem[] {
return items.flatMap((item) => [item, ...flattenNavItems(item.children || [])]);
}

View File

@ -1,155 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { getMenus } from "./api/navigation.js";
import { useAuth } from "./auth/AuthProvider.jsx";
import { Header } from "./components/layout/Header.jsx";
import { PageSkeleton } from "./components/layout/PageSkeleton.jsx";
import { Sidebar } from "./components/layout/Sidebar.jsx";
import { findNavItemByPath, mapBackendMenus, navItems } from "./constants/navigation.js";
const pageModules = {
login: () => import("./pages/LoginPage.jsx").then((module) => module.LoginPage),
overview: () => import("./pages/OverviewPage.jsx").then((module) => module.OverviewPage),
services: () => import("./pages/ServicesPage.jsx").then((module) => module.ServicesPage),
users: () => import("./pages/UserManagementPage.jsx").then((module) => module.UserManagementPage),
roles: () => import("./pages/RoleManagementPage.jsx").then((module) => module.RoleManagementPage),
menus: () => import("./pages/MenuManagementPage.jsx").then((module) => module.MenuManagementPage),
logs: () => import("./pages/LogsPage.jsx").then((module) => module.LogsPage),
notifications: () => import("./pages/NotificationsPage.jsx").then((module) => module.NotificationsPage)
};
const pageCache = new Map();
function App() {
return (
<Routes>
<Route path="/login" element={<DeferredPage pageKey="login" />} />
<Route element={<RequireAuth />}>
<Route element={<AdminLayout />}>
<Route index element={<Navigate to="/overview" replace />} />
<Route path="/overview" element={<RequirePermission code="overview:view"><DeferredPage pageKey="overview" /></RequirePermission>} />
<Route path="/services" element={<RequirePermission code="service:view"><DeferredPage pageKey="services" /></RequirePermission>} />
<Route path="/system/users" element={<RequirePermission code="user:view"><DeferredPage pageKey="users" /></RequirePermission>} />
<Route path="/system/roles" element={<RequirePermission code="role:view"><DeferredPage pageKey="roles" /></RequirePermission>} />
<Route path="/system/menus" element={<RequirePermission code="menu:view"><DeferredPage pageKey="menus" /></RequirePermission>} />
<Route path="/logs/login" element={<RequirePermission code="log:view"><DeferredPage pageKey="logs" type="login" /></RequirePermission>} />
<Route path="/logs/operations" element={<RequirePermission code="log:view"><DeferredPage pageKey="logs" type="operations" /></RequirePermission>} />
<Route path="/notifications" element={<RequirePermission code="notification:view"><DeferredPage pageKey="notifications" /></RequirePermission>} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/overview" replace />} />
</Routes>
);
}
function DeferredPage({ pageKey, ...props }) {
const [Page, setPage] = useState(() => pageCache.get(pageKey) || null);
useEffect(() => {
let mounted = true;
if (pageCache.has(pageKey)) {
setPage(() => pageCache.get(pageKey));
return undefined;
}
setPage(null);
pageModules[pageKey]().then((component) => {
pageCache.set(pageKey, component);
if (mounted) {
setPage(() => component);
}
});
return () => {
mounted = false;
};
}, [pageKey]);
if (!Page) {
return <PageSkeleton />;
}
return <Page {...props} />;
}
function RequireAuth() {
const { loading, user } = useAuth();
const location = useLocation();
if (loading) {
return <PageSkeleton />;
}
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />;
}
function RequirePermission({ children, code }) {
const { can } = useAuth();
if (!can(code)) {
return <Navigate to="/overview" replace />;
}
return children;
}
function AdminLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [refreshTick, setRefreshTick] = useState(0);
const [backendMenus, setBackendMenus] = useState([]);
const location = useLocation();
const navigate = useNavigate();
const activeNav = useMemo(() => findNavItemByPath(location.pathname, backendMenus.length ? backendMenus : navItems), [backendMenus, location.pathname]);
useEffect(() => {
let mounted = true;
async function loadMenus() {
try {
const data = await getMenus();
if (mounted) {
setBackendMenus(mapBackendMenus(data));
}
} catch {
if (mounted) {
setBackendMenus(navItems);
}
}
}
loadMenus();
return () => {
mounted = false;
};
}, []);
const menus = backendMenus.length ? backendMenus : navItems;
return (
<div className={`app-shell ${sidebarCollapsed ? "app-shell--sidebar-collapsed" : ""}`}>
<Header
activeLabel={activeNav?.label || "总览"}
isSidebarCollapsed={sidebarCollapsed}
onRefresh={() => setRefreshTick((current) => current + 1)}
onToggleSidebar={() => setSidebarCollapsed((current) => !current)}
/>
<div className="body-grid">
<Sidebar activePath={location.pathname} isCollapsed={sidebarCollapsed} items={menus} onNavigate={navigate} />
<main className="workspace">
<div className="page-transition" key={location.pathname}>
<Outlet context={{ refreshTick }} />
</div>
</main>
</div>
</div>
);
}
export default App;

View File

@ -1,27 +1,45 @@
import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";
import { ThemeProvider } from "@mui/material/styles";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { AuthProvider } from "./auth/AuthProvider.jsx";
import { ConfirmProvider } from "./components/base/ConfirmProvider.jsx";
import { ToastProvider } from "./components/base/ToastProvider.jsx";
import { theme } from "./theme.js";
import App from "./App.jsx";
import App from "@/app/App.jsx";
import { AppProviders } from "@/app/providers.jsx";
import { adminRoutes } from "@/app/router/routeConfig";
beforeEach(() => {
window.localStorage.clear();
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ code: 401, message: "unauthorized" }), { status: 401 }))
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
test("renders login route", async () => {
render(
<ThemeProvider theme={theme}>
<ToastProvider>
<ConfirmProvider>
<MemoryRouter initialEntries={["/login"]}>
<AuthProvider>
<App />
</AuthProvider>
</MemoryRouter>
</ConfirmProvider>
</ToastProvider>
</ThemeProvider>
);
renderWithRoute("/login");
expect(await screen.findByText("HYApp 管理平台")).toBeInTheDocument();
});
test("redirects protected route to login when unauthenticated", async () => {
renderWithRoute("/system/users");
expect(await screen.findByText("HYApp 管理平台")).toBeInTheDocument();
});
test("admin routes declare menu code and permission", () => {
expect(adminRoutes.length).toBeGreaterThan(0);
expect(adminRoutes.every((route) => route.menuCode && route.permission)).toBe(true);
});
function renderWithRoute(route) {
return render(
<AppProviders>
<MemoryRouter initialEntries={[route]}>
<App />
</MemoryRouter>
</AppProviders>
);
}

View File

@ -1,21 +0,0 @@
import { apiRequest } from "./request.js";
export function login(payload) {
return apiRequest("/v1/auth/login", { body: payload, skipAuth: true });
}
export function logout() {
return apiRequest("/v1/auth/logout", { method: "POST", skipAuth: true });
}
export function refreshSession() {
return apiRequest("/v1/auth/refresh", { method: "POST", skipAuth: true });
}
export function getMe() {
return apiRequest("/v1/auth/me");
}
export function changePassword(payload) {
return apiRequest("/v1/auth/change-password", { body: payload });
}

View File

@ -1,5 +0,0 @@
import { apiRequest } from "./request.js";
export function getDashboardOverview() {
return apiRequest("/v1/dashboard/overview");
}

View File

@ -1,9 +0,0 @@
import { apiRequest } from "./request.js";
export function listLoginLogs(query) {
return apiRequest("/v1/logs/login", { query });
}
export function listOperationLogs(query) {
return apiRequest("/v1/logs/operations", { query });
}

View File

@ -1,5 +0,0 @@
import { apiRequest } from "./request.js";
export function getMenus() {
return apiRequest("/v1/navigation/menus");
}

View File

@ -1,9 +0,0 @@
import { apiRequest } from "./request.js";
export function listNotifications() {
return apiRequest("/v1/notifications");
}
export function markNotificationRead(id) {
return apiRequest(`/v1/notifications/${id}/read`, { method: "PATCH", body: {} });
}

View File

@ -1,105 +0,0 @@
import { API_BASE_URL } from "../config/env.js";
const TOKEN_KEY = "hyapp-admin.access-token";
let accessToken = window.localStorage.getItem(TOKEN_KEY) || "";
let refreshHandler = null;
let unauthorizedHandler = null;
export function getAccessToken() {
return accessToken;
}
export function setAccessToken(token) {
accessToken = token || "";
if (accessToken) {
window.localStorage.setItem(TOKEN_KEY, accessToken);
} else {
window.localStorage.removeItem(TOKEN_KEY);
}
}
export function setRefreshHandler(handler) {
refreshHandler = handler;
}
export function setUnauthorizedHandler(handler) {
unauthorizedHandler = handler;
}
export async function apiRequest(path, options = {}) {
const {
body,
headers = {},
method = body ? "POST" : "GET",
query,
retry = true,
raw = false,
skipAuth = false
} = options;
const url = buildURL(path, query);
const requestHeaders = { ...headers };
if (body !== undefined && !(body instanceof FormData)) {
requestHeaders["Content-Type"] = "application/json";
}
if (accessToken && !skipAuth) {
requestHeaders.Authorization = `Bearer ${accessToken}`;
}
const response = await fetch(url, {
method,
headers: requestHeaders,
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
credentials: "include"
});
if (response.status === 401 && retry && refreshHandler && !skipAuth) {
const refreshed = await refreshHandler();
if (refreshed) {
return apiRequest(path, { ...options, retry: false });
}
}
if (response.status === 401 && unauthorizedHandler && !skipAuth) {
unauthorizedHandler();
}
if (raw) {
if (!response.ok) {
throw new Error(response.statusText || "请求失败");
}
return response;
}
const payload = await readJSON(response);
if (!response.ok || payload.code !== 0) {
throw new Error(payload.message || response.statusText || "请求失败");
}
return payload.data;
}
function buildURL(path, query) {
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
if (query) {
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, value);
}
});
}
return url.toString();
}
async function readJSON(response) {
const text = await response.text();
if (!text) {
return {};
}
try {
return JSON.parse(text);
} catch {
return { code: response.ok ? 0 : response.status, message: text, data: text };
}
}

View File

@ -1,25 +0,0 @@
import { apiRequest } from "./request.js";
export function listRoles() {
return apiRequest("/v1/roles");
}
export function createRole(payload) {
return apiRequest("/v1/roles", { body: payload });
}
export function updateRole(id, payload) {
return apiRequest(`/v1/roles/${id}`, { method: "PATCH", body: payload });
}
export function deleteRole(id) {
return apiRequest(`/v1/roles/${id}`, { method: "DELETE" });
}
export function replaceRolePermissions(id, permissionIds) {
return apiRequest(`/v1/roles/${id}/permissions`, { method: "PUT", body: { permissionIds } });
}
export function listPermissions() {
return apiRequest("/v1/permissions");
}

View File

@ -1,5 +0,0 @@
import { apiRequest } from "./request.js";
export function searchGlobal(keyword) {
return apiRequest("/v1/search", { query: { keyword } });
}

View File

@ -1,25 +0,0 @@
import { apiRequest } from "./request.js";
export function listServices(query) {
return apiRequest("/v1/services", { query });
}
export function getService(id) {
return apiRequest(`/v1/services/${id}`);
}
export function createService(payload) {
return apiRequest("/v1/services", { body: payload });
}
export function updateService(id, payload) {
return apiRequest(`/v1/services/${id}`, { method: "PATCH", body: payload });
}
export function updateServiceStatus(id, status) {
return apiRequest(`/v1/services/${id}/status`, { method: "PATCH", body: { status } });
}
export function restartService(id) {
return apiRequest(`/v1/services/${id}/restart`, { body: {} });
}

View File

@ -1,33 +0,0 @@
import { apiRequest } from "./request.js";
export function listUsers(query) {
return apiRequest("/v1/users", { query });
}
export function getUser(id) {
return apiRequest(`/v1/users/${id}`);
}
export function createUser(payload) {
return apiRequest("/v1/users", { body: payload });
}
export function updateUser(id, payload) {
return apiRequest(`/v1/users/${id}`, { method: "PATCH", body: payload });
}
export function updateUserStatus(id, status) {
return apiRequest(`/v1/users/${id}/status`, { method: "PATCH", body: { status } });
}
export function resetUserPassword(id, password) {
return apiRequest(`/v1/users/${id}/reset-password`, { body: { password } });
}
export function batchUpdateUserStatus(ids, status) {
return apiRequest("/v1/users/batch/status", { body: { ids, status } });
}
export function exportUsers(query) {
return apiRequest("/v1/users/export", { query, raw: true });
}

34
src/app/App.jsx Normal file
View File

@ -0,0 +1,34 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { AdminLayout } from "@/app/layout/AdminLayout.jsx";
import { DeferredPage } from "@/app/router/DeferredPage.jsx";
import { RequireAuth, RequirePermission } from "@/app/router/guards.jsx";
import { adminRoutes, defaultAdminPath, publicRoutes } from "@/app/router/routeConfig";
function App() {
return (
<Routes>
{publicRoutes.map((route) => (
<Route key={route.path} path={route.path} element={<DeferredPage route={route} />} />
))}
<Route element={<RequireAuth />}>
<Route element={<AdminLayout />}>
<Route index element={<Navigate to={defaultAdminPath} replace />} />
{adminRoutes.map((route) => (
<Route
key={route.path}
path={route.path}
element={
<RequirePermission code={route.permission}>
<DeferredPage route={route} />
</RequirePermission>
}
/>
))}
</Route>
</Route>
<Route path="*" element={<Navigate to={defaultAdminPath} replace />} />
</Routes>
);
}
export default App;

41
src/app/ErrorBoundary.jsx Normal file
View File

@ -0,0 +1,41 @@
import { Component } from "react";
import styles from "./ErrorBoundary.module.css";
export class ErrorBoundary extends Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error };
}
reset = () => {
this.setState({ error: null });
};
reload = () => {
window.location.reload();
};
render() {
if (!this.state.error) {
return this.props.children;
}
return (
<main className={styles.root}>
<section className={styles.panel}>
<h1 className={styles.title}>页面加载失败</h1>
<p className={styles.message}>{this.state.error.message || "请刷新后重试"}</p>
<div className={styles.actions}>
<button className={styles.secondaryButton} type="button" onClick={this.reset}>
返回页面
</button>
<button className={styles.primaryButton} type="button" onClick={this.reload}>
刷新
</button>
</div>
</section>
</main>
);
}
}

View File

@ -0,0 +1,62 @@
.root {
align-items: center;
background: var(--bg-page);
display: flex;
min-height: 100vh;
padding: var(--space-6);
}
.panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-panel);
margin: 0 auto;
max-width: 420px;
padding: var(--space-6);
width: 100%;
}
.title {
color: var(--text-primary);
font-size: var(--admin-font-size);
line-height: var(--admin-line-height);
margin: 0;
}
.message {
color: var(--text-secondary);
line-height: var(--admin-line-height);
margin: var(--space-3) 0 0;
overflow-wrap: anywhere;
}
.actions {
display: flex;
gap: var(--space-3);
justify-content: flex-end;
margin-top: var(--space-5);
}
.primaryButton,
.secondaryButton {
align-items: center;
border: 1px solid transparent;
border-radius: var(--radius-control);
cursor: pointer;
display: inline-flex;
font: inherit;
min-height: var(--control-height);
padding: 0 var(--space-4);
}
.primaryButton {
background: var(--primary);
color: var(--active-contrast);
}
.secondaryButton {
background: var(--bg-card);
border-color: var(--border);
color: var(--text-primary);
}

View File

@ -0,0 +1,71 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { listAdminApps } from "@/features/app-registry/api";
import { getSelectedAppCode, setSelectedAppCode as setRequestAppCode } from "@/shared/api/request";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
const AppScopeContext = createContext(null);
const fallbackAppCode = "lalu";
const emptyAppState = { items: [], total: 0 };
export function AppScopeProvider({ children }) {
const { user } = useAuth();
const queryClient = useQueryClient();
const [appCode, setAppCodeState] = useState(() => getSelectedAppCode() || fallbackAppCode);
const { data = emptyAppState, error, loading } = useAdminQuery(() => listAdminApps(), {
enabled: Boolean(user),
errorMessage: "加载 App 列表失败",
initialData: emptyAppState,
queryKey: ["admin-apps"],
staleTime: 5 * 60 * 1000
});
const apps = useMemo(() => data?.items || [], [data?.items]);
const applyAppCode = useCallback(
async (nextAppCode, options = {}) => {
const normalized = normalizeAppCode(nextAppCode) || fallbackAppCode;
setRequestAppCode(normalized);
setAppCodeState(normalized);
if (!options.skipRefresh) {
await queryClient.invalidateQueries({ refetchType: "active" });
}
},
[queryClient]
);
useEffect(() => {
if (!apps.length) {
return;
}
const selectedExists = apps.some((item) => normalizeAppCode(item.appCode) === normalizeAppCode(appCode));
if (!selectedExists) {
void applyAppCode(apps[0].appCode, { skipRefresh: false });
}
}, [appCode, applyAppCode, apps]);
const value = useMemo(
() => ({
appCode,
apps,
error,
loading,
setAppCode: applyAppCode
}),
[appCode, applyAppCode, apps, error, loading]
);
return <AppScopeContext.Provider value={value}>{children}</AppScopeContext.Provider>;
}
export function useAppScope() {
const context = useContext(AppScopeContext);
if (!context) {
throw new Error("useAppScope must be used inside AppScopeProvider");
}
return context;
}
function normalizeAppCode(value) {
return String(value || "").trim().toLowerCase();
}

View File

@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import * as authApi from "../api/auth.js";
import { getAccessToken, setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "../api/request.js";
import * as authApi from "@/features/auth/api";
import { getAccessToken, setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/shared/api/request";
const AuthContext = createContext(null);
@ -49,10 +49,23 @@ export function AuthProvider({ children }) {
async function bootstrap() {
try {
if (getAccessToken()) {
try {
const session = await authApi.refreshSession();
if (mounted) {
applySession(session);
}
return;
} catch {
// Keep the existing access token as a fallback when the refresh cookie is unavailable.
}
try {
const session = await authApi.getMe();
if (mounted) {
applySession(session);
}
} catch {
clearSession();
}
return;
}
await refresh();
@ -67,7 +80,7 @@ export function AuthProvider({ children }) {
return () => {
mounted = false;
};
}, [applySession, refresh]);
}, [applySession, clearSession, refresh]);
const login = useCallback(
async (payload) => {

View File

@ -0,0 +1,58 @@
import { useMemo, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { getMenus } from "@/features/menus/api";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useRefreshSignal } from "@/shared/hooks/useRefreshSignal.js";
import {
fallbackNavigation,
filterNavigationByPermission,
findNavItemByPath,
mapBackendMenus,
mergeNavigationItems
} from "@/app/navigation/menu.js";
import { Header } from "./Header.jsx";
import { Sidebar } from "./Sidebar.jsx";
export function AdminLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const { can } = useAuth();
const { refresh } = useRefreshSignal();
const { data: backendMenus } = useAdminQuery(() => getMenus(), {
errorMessage: "加载菜单失败",
initialData: [],
queryKey: ["navigation", "menus"],
staleTime: 5 * 60 * 1000
});
const menus = useMemo(() => {
const fallbackMenus = filterNavigationByPermission(fallbackNavigation, can);
if (!backendMenus?.length) {
return fallbackMenus;
}
return mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus);
}, [backendMenus, can]);
const activeNav = useMemo(() => findNavItemByPath(location.pathname, menus), [location.pathname, menus]);
return (
<div className={`app-shell ${sidebarCollapsed ? "app-shell--sidebar-collapsed" : ""}`}>
<Header
activeLabel={activeNav?.label || "总览"}
isSidebarCollapsed={sidebarCollapsed}
onRefresh={refresh}
onToggleSidebar={() => setSidebarCollapsed((current) => !current)}
/>
<div className="body-grid">
<Sidebar activePath={location.pathname} isCollapsed={sidebarCollapsed} items={menus} onNavigate={navigate} />
<main className="workspace">
<div className="page-transition" key={location.pathname}>
<Outlet />
</div>
</main>
</div>
</div>
);
}

View File

@ -9,25 +9,37 @@ import Refresh from "@mui/icons-material/Refresh";
import Search from "@mui/icons-material/Search";
import MuiMenu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { lazy, Suspense, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { listNotifications } from "../../api/notifications.js";
import { useAuth } from "../../auth/AuthProvider.jsx";
import { IconButton } from "../base/IconButton.jsx";
import { useToast } from "../base/ToastProvider.jsx";
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
import { listNotifications } from "@/features/notifications/api";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const SearchDialog = lazy(() => import("../search/SearchDialog.jsx").then((module) => ({ default: module.SearchDialog })));
const SearchDialog = lazy(() => import("@/features/search/components/SearchDialog.jsx").then((module) => ({ default: module.SearchDialog })));
export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSidebar }) {
const [searchOpen, setSearchOpen] = useState(false);
const [searchMounted, setSearchMounted] = useState(false);
const [userMenuAnchor, setUserMenuAnchor] = useState(null);
const [unread, setUnread] = useState(0);
const navigate = useNavigate();
const { logout, user } = useAuth();
const { can, logout, user } = useAuth();
const appScope = useAppScope();
const { showToast } = useToast();
const ToggleIcon = isSidebarCollapsed ? MenuOpen : Menu;
const userMenuOpen = Boolean(userMenuAnchor);
const canViewNotifications = can(PERMISSIONS.notificationView);
const { data: notificationState } = useAdminQuery(() => listNotifications(), {
enabled: canViewNotifications,
initialData: { items: [], unread: 0 },
queryKey: ["notifications", "unread"],
refetchInterval: 30000
});
const unread = notificationState?.unread || 0;
useEffect(() => {
if (searchOpen) {
@ -64,30 +76,6 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
navigate("/notifications");
};
useEffect(() => {
let mounted = true;
async function loadUnread() {
try {
const data = await listNotifications();
if (mounted) {
setUnread(data.unread || 0);
}
} catch {
if (mounted) {
setUnread(0);
}
}
}
loadUnread();
const timer = window.setInterval(loadUnread, 30000);
return () => {
mounted = false;
window.clearInterval(timer);
};
}, []);
const refresh = () => {
onRefresh();
showToast("已刷新当前页面", "success");
@ -114,16 +102,36 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
</div>
<div className="header-right-tools">
<TextField
className="app-switch"
disabled={appScope.loading || !appScope.apps.length}
select
size="small"
value={appScope.appCode}
onChange={(event) => appScope.setAppCode(event.target.value)}
>
{appScope.apps.length ? (
appScope.apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}>
{app.appName || app.appCode}
</MenuItem>
))
) : (
<MenuItem value={appScope.appCode}>{appScope.appCode}</MenuItem>
)}
</TextField>
<button className="search-trigger" type="button" onClick={openSearch}>
<Search fontSize="small" />
<span>搜索用户服务IP...</span>
<span>搜索用户角色...</span>
</button>
<IconButton label="刷新" onClick={refresh}>
<Refresh fontSize="small" />
</IconButton>
{canViewNotifications ? (
<IconButton label="通知" notice={unread ? String(unread) : undefined} onClick={openNotifications}>
<NotificationsNoneOutlined fontSize="small" />
</IconButton>
) : null}
<button className="user-pill" type="button" onClick={(event) => setUserMenuAnchor(event.currentTarget)}>
<Person fontSize="small" />
<span>{user?.name || user?.account || "admin"}</span>

224
src/app/navigation/menu.js Normal file
View File

@ -0,0 +1,224 @@
import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined";
import BedroomParentOutlined from "@mui/icons-material/BedroomParentOutlined";
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
import HubOutlined from "@mui/icons-material/HubOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import LoginOutlined from "@mui/icons-material/LoginOutlined";
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
import MapOutlined from "@mui/icons-material/MapOutlined";
import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
import NotificationsNoneOutlined from "@mui/icons-material/NotificationsNoneOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined";
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
import SendOutlined from "@mui/icons-material/SendOutlined";
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicationsOutlined";
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
import WalletOutlined from "@mui/icons-material/WalletOutlined";
import { getRouteByMenuCode } from "@/app/router/routeConfig";
const deprecatedMenuCodes = new Set(["services"]);
const iconMap = {
apartment: ApartmentOutlined,
category: CategoryOutlined,
dashboard: DashboardOutlined,
gift: CardGiftcardOutlined,
inventory: Inventory2Outlined,
network: HubOutlined,
team: GroupsOutlined,
history: HistoryOutlined,
login: LoginOutlined,
map: MapOutlined,
menu: MenuOpenOutlined,
notifications: NotificationsNoneOutlined,
public: PublicOutlined,
receipt: ReceiptLongOutlined,
room: BedroomParentOutlined,
send: SendOutlined,
settings: SettingsApplicationsOutlined,
shield: ShieldOutlined,
task: TaskAltOutlined,
users: ManageAccountsOutlined,
wallet: WalletOutlined
};
export const fallbackNavigation = [
routeNavItem("overview", { icon: DashboardOutlined }),
{
code: "system",
icon: SettingsApplicationsOutlined,
id: "system",
label: "系统管理",
children: [
routeNavItem("system-users", { icon: ManageAccountsOutlined }),
routeNavItem("system-roles", { icon: ShieldOutlined }),
routeNavItem("system-menus", { icon: MenuOpenOutlined })
]
},
{
code: "app-users",
icon: ManageAccountsOutlined,
id: "app-users",
label: "用户管理",
children: [
routeNavItem("app-user-list", { icon: ManageAccountsOutlined })
]
},
{
code: "rooms",
icon: BedroomParentOutlined,
id: "rooms",
label: "房间管理",
children: [
routeNavItem("room-list", { icon: BedroomParentOutlined })
]
},
{
code: "app-config",
icon: SettingsApplicationsOutlined,
id: "app-config",
label: "APP配置",
children: [
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined })
]
},
{
code: "resources",
icon: Inventory2Outlined,
id: "resources",
label: "资源管理",
children: [
routeNavItem("resource-list", { icon: Inventory2Outlined }),
routeNavItem("resource-group-list", { icon: CategoryOutlined }),
routeNavItem("gift-list", { icon: CardGiftcardOutlined }),
routeNavItem("resource-grant-list", { icon: SendOutlined })
]
},
{
code: "logs",
icon: HistoryOutlined,
id: "logs",
label: "日志审计",
children: [
routeNavItem("logs-login", { icon: LoginOutlined }),
routeNavItem("logs-operations", { icon: ReceiptLongOutlined })
]
},
{
code: "geo",
icon: MapOutlined,
id: "geo",
label: "地区管理",
children: [
routeNavItem("host-org-countries", { icon: PublicOutlined }),
routeNavItem("host-org-regions", { icon: MapOutlined })
]
},
{
code: "host-org",
icon: HubOutlined,
id: "host-org",
label: "团队管理",
children: [
routeNavItem("host-org-agencies", { icon: ApartmentOutlined }),
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
routeNavItem("host-org-hosts", { icon: ManageAccountsOutlined }),
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined })
]
},
routeNavItem("notifications", { icon: NotificationsNoneOutlined })
];
export function mapBackendMenus(menus = []) {
return menus
.map((item) => {
if (deprecatedMenuCodes.has(item.code)) {
return null;
}
const route = item.code ? getRouteByMenuCode(item.code) : undefined;
const children = item.children?.length ? mapBackendMenus(item.children) : undefined;
return {
children: children?.length ? children : undefined,
code: item.code,
icon: iconMap[item.icon] || MenuOpenOutlined,
id: item.code || String(item.id),
label: item.label,
path: route?.path || item.path,
permissionCode: route?.permission || item.permissionCode
};
})
.filter(Boolean);
}
export function filterNavigationByPermission(items = [], can) {
return items
.map((item) => {
const children = filterNavigationByPermission(item.children || [], can);
const canViewItem = !item.permissionCode || can(item.permissionCode);
if (children.length || (item.path && canViewItem)) {
return { ...item, children: children.length ? children : undefined };
}
return null;
})
.filter(Boolean);
}
export function mergeNavigationItems(primaryItems = [], fallbackItems = []) {
const merged = primaryItems.map((item) => ({ ...item }));
const itemByCode = new Map(merged.map((item) => [item.code, item]));
fallbackItems.forEach((fallbackItem) => {
const existing = itemByCode.get(fallbackItem.code);
if (!existing) {
merged.push(fallbackItem);
return;
}
if (fallbackItem.children?.length) {
existing.children = mergeNavigationItems(existing.children || [], fallbackItem.children);
}
});
return merged;
}
export function findNavItemByPath(pathname, items = fallbackNavigation) {
let fallback = null;
for (const item of items) {
if (item.path && pathname.startsWith(item.path)) {
fallback = item;
}
if (item.children?.length) {
const child = findNavItemByPath(pathname, item.children);
if (child) {
return child;
}
}
}
return fallback;
}
function routeNavItem(menuCode, options = {}) {
const route = getRouteByMenuCode(menuCode);
return {
code: menuCode,
icon: options.icon || MenuOpenOutlined,
id: menuCode,
label: route?.label || menuCode,
path: route?.path,
permissionCode: route?.permission
};
}

View File

@ -0,0 +1,47 @@
import { describe, expect, test } from "vitest";
import {
fallbackNavigation,
filterNavigationByPermission,
mergeNavigationItems
} from "./menu.js";
describe("navigation menu helpers", () => {
test("keeps only fallback menu items allowed by permissions", () => {
const items = filterNavigationByPermission(fallbackNavigation, (code) => code === "agency:view" || code === "host:view");
expect(flattenCodes(items)).not.toContain("geo");
expect(flattenCodes(items)).toContain("host-org");
expect(flattenCodes(items)).toContain("host-org-agencies");
expect(flattenCodes(items)).toContain("host-org-hosts");
expect(flattenCodes(items)).not.toContain("host-org-bds");
});
test("places country and region pages under geo menu", () => {
const items = filterNavigationByPermission(fallbackNavigation, (code) => code === "country:view" || code === "region:view");
const geo = items.find((item) => item.code === "geo");
expect(geo).toBeTruthy();
expect(geo.children.map((item) => item.code)).toEqual(["host-org-countries", "host-org-regions"]);
expect(flattenCodes(items)).not.toContain("host-org");
});
test("adds missing permitted fallback children to backend menu groups", () => {
const backendMenus = [
{
children: [],
code: "host-org",
icon: fallbackNavigation.find((item) => item.code === "host-org").icon,
id: "host-org",
label: "团队管理"
}
];
const fallbackMenus = filterNavigationByPermission(fallbackNavigation, (code) => code === "agency:view");
const merged = mergeNavigationItems(backendMenus, fallbackMenus);
expect(flattenCodes(merged)).toContain("host-org-agencies");
});
});
function flattenCodes(items) {
return items.flatMap((item) => [item.code, ...flattenCodes(item.children || [])]);
}

117
src/app/permissions.ts Normal file
View File

@ -0,0 +1,117 @@
export const PERMISSIONS = {
overviewView: "overview:view",
userView: "user:view",
userCreate: "user:create",
userUpdate: "user:update",
userStatus: "user:status",
userResetPassword: "user:reset-password",
userExport: "user:export",
countryView: "country:view",
countryCreate: "country:create",
countryUpdate: "country:update",
countryStatus: "country:status",
regionView: "region:view",
regionCreate: "region:create",
regionUpdate: "region:update",
regionStatus: "region:status",
hostView: "host:view",
agencyView: "agency:view",
agencyCreate: "agency:create",
agencyStatus: "agency:status",
bdView: "bd:view",
bdCreate: "bd:create",
bdUpdate: "bd:update",
coinSellerView: "coin-seller:view",
coinSellerCreate: "coin-seller:create",
coinSellerUpdate: "coin-seller:update",
roleView: "role:view",
roleCreate: "role:create",
roleUpdate: "role:update",
roleDelete: "role:delete",
roleDataScope: "role:data-scope",
rolePermission: "role:permission",
roleManage: "role:manage",
permissionView: "permission:view",
permissionCreate: "permission:create",
permissionUpdate: "permission:update",
permissionDelete: "permission:delete",
permissionSync: "permission:sync",
menuView: "menu:view",
menuCreate: "menu:create",
menuUpdate: "menu:update",
menuDelete: "menu:delete",
menuSort: "menu:sort",
menuVisible: "menu:visible",
logView: "log:view",
logExport: "log:export",
notificationView: "notification:view",
notificationRead: "notification:read",
notificationReadAll: "notification:read-all",
notificationDelete: "notification:delete",
appUserView: "app-user:view",
appUserUpdate: "app-user:update",
appUserStatus: "app-user:status",
appUserPassword: "app-user:password",
roomView: "room:view",
roomUpdate: "room:update",
roomDelete: "room:delete",
appConfigView: "app-config:view",
appConfigUpdate: "app-config:update",
resourceView: "resource:view",
resourceCreate: "resource:create",
resourceUpdate: "resource:update",
resourceGroupView: "resource-group:view",
resourceGroupCreate: "resource-group:create",
resourceGroupUpdate: "resource-group:update",
resourceGrantView: "resource-grant:view",
resourceGrantCreate: "resource-grant:create",
giftView: "gift:view",
giftCreate: "gift:create",
giftUpdate: "gift:update",
giftStatus: "gift:status",
uploadCreate: "upload:create",
jobView: "job:view",
jobCancel: "job:cancel",
exportCreate: "export:create"
} as const;
export type PermissionCode = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
export const PERMISSION_CODES = Object.values(PERMISSIONS) as PermissionCode[];
export const MENU_CODES = {
overview: "overview",
system: "system",
systemUsers: "system-users",
systemRoles: "system-roles",
systemMenus: "system-menus",
logs: "logs",
logsLogin: "logs-login",
logsOperations: "logs-operations",
notifications: "notifications",
appUsers: "app-users",
appUserList: "app-user-list",
rooms: "rooms",
roomList: "room-list",
appConfig: "app-config",
appConfigH5: "app-config-h5",
resources: "resources",
resourceList: "resource-list",
resourceGroupList: "resource-group-list",
resourceGrantList: "resource-grant-list",
giftList: "gift-list",
geo: "geo",
hostOrg: "host-org",
hostOrgCountries: "host-org-countries",
hostOrgRegions: "host-org-regions",
hostOrgAgencies: "host-org-agencies",
hostOrgBdLeaders: "host-org-bd-leaders",
hostOrgBds: "host-org-bds",
hostOrgHosts: "host-org-hosts",
hostOrgCoinSellers: "host-org-coin-sellers",
jobs: "jobs"
} as const;
export type MenuCode = (typeof MENU_CODES)[keyof typeof MENU_CODES];
export const MENU_CODE_VALUES = Object.values(MENU_CODES) as MenuCode[];

26
src/app/providers.jsx Normal file
View File

@ -0,0 +1,26 @@
import { ThemeProvider } from "@mui/material/styles";
import { AppScopeProvider } from "@/app/app-scope/AppScopeProvider.jsx";
import { AuthProvider } from "@/app/auth/AuthProvider.jsx";
import { ConfirmProvider } from "@/shared/ui/ConfirmProvider.jsx";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { QueryProvider } from "@/shared/query/QueryProvider.jsx";
import { RefreshProvider } from "@/shared/hooks/useRefreshSignal.js";
import { theme } from "@/theme.js";
export function AppProviders({ children }) {
return (
<ThemeProvider theme={theme}>
<QueryProvider>
<ToastProvider>
<ConfirmProvider>
<RefreshProvider>
<AuthProvider>
<AppScopeProvider>{children}</AppScopeProvider>
</AuthProvider>
</RefreshProvider>
</ConfirmProvider>
</ToastProvider>
</QueryProvider>
</ThemeProvider>
);
}

View File

@ -0,0 +1,35 @@
import { useEffect, useState } from "react";
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
const pageCache = new Map();
export function DeferredPage({ route }) {
const [Page, setPage] = useState(() => pageCache.get(route.pageKey) || null);
useEffect(() => {
let mounted = true;
if (pageCache.has(route.pageKey)) {
setPage(() => pageCache.get(route.pageKey));
return undefined;
}
setPage(null);
route.loader().then((component) => {
pageCache.set(route.pageKey, component);
if (mounted) {
setPage(() => component);
}
});
return () => {
mounted = false;
};
}, [route]);
if (!Page) {
return <PageSkeleton />;
}
return <Page {...(route.props || {})} />;
}

29
src/app/router/guards.jsx Normal file
View File

@ -0,0 +1,29 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
import { defaultAdminPath } from "./routeConfig";
export function RequireAuth() {
const { loading, user } = useAuth();
const location = useLocation();
if (loading) {
return <PageSkeleton />;
}
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />;
}
export function RequirePermission({ children, code }) {
const { can } = useAuth();
if (!can(code)) {
return <Navigate to={defaultAdminPath} replace />;
}
return children;
}

View File

@ -0,0 +1,45 @@
import { describe, expect, test } from "vitest";
import { MENU_CODE_VALUES, PERMISSION_CODES } from "@/app/permissions";
import { fallbackNavigation } from "@/app/navigation/menu";
import { adminRoutes } from "@/app/router/routeConfig";
type TestNavItem = {
children?: TestNavItem[];
code: string;
path?: string;
permissionCode?: string;
};
describe("admin route config", () => {
test("uses unique paths, page keys and menu codes", () => {
expect(new Set(adminRoutes.map((route) => route.path)).size).toBe(adminRoutes.length);
expect(new Set(adminRoutes.map((route) => route.pageKey)).size).toBe(adminRoutes.length);
expect(new Set(adminRoutes.map((route) => route.menuCode)).size).toBe(adminRoutes.length);
});
test("uses known menu codes and permission codes", () => {
const menuCodes = new Set(MENU_CODE_VALUES);
const permissions = new Set(PERMISSION_CODES);
for (const route of adminRoutes) {
expect(menuCodes.has(route.menuCode)).toBe(true);
expect(permissions.has(route.permission)).toBe(true);
}
});
test("fallback leaf menus match routes", () => {
const routesByMenuCode = new Map<string, (typeof adminRoutes)[number]>(
adminRoutes.map((route) => [route.menuCode, route])
);
for (const item of flattenNavigation(fallbackNavigation as TestNavItem[]).filter((navItem) => navItem.path)) {
const route = routesByMenuCode.get(item.code);
expect(route?.path).toBe(item.path);
expect(route?.permission).toBe(item.permissionCode);
}
});
});
function flattenNavigation(items: TestNavItem[]): TestNavItem[] {
return items.flatMap((item) => [item, ...flattenNavigation(item.children || [])]);
}

View File

@ -0,0 +1,38 @@
import { authRoutes } from "@/features/auth/routes.js";
import { appConfigRoutes } from "@/features/app-config/routes.js";
import { appUserRoutes } from "@/features/app-users/routes.js";
import { dashboardRoutes } from "@/features/dashboard/routes.js";
import { hostOrgRoutes } from "@/features/host-org/routes.js";
import { logsRoutes } from "@/features/logs/routes.js";
import { menusRoutes } from "@/features/menus/routes.js";
import { notificationsRoutes } from "@/features/notifications/routes.js";
import { resourceRoutes } from "@/features/resources/routes.js";
import { rolesRoutes } from "@/features/roles/routes.js";
import { roomRoutes } from "@/features/rooms/routes.js";
import { usersRoutes } from "@/features/users/routes.js";
import type { MenuCode } from "@/app/permissions";
import type { AdminRoute, PublicRoute } from "./types";
export const publicRoutes: PublicRoute[] = authRoutes;
export const adminRoutes: AdminRoute[] = [
...dashboardRoutes,
...appUserRoutes,
...roomRoutes,
...appConfigRoutes,
...resourceRoutes,
...usersRoutes,
...hostOrgRoutes,
...rolesRoutes,
...menusRoutes,
...logsRoutes,
...notificationsRoutes
];
export const defaultAdminPath = "/overview";
const routeByMenuCode = new Map<string, AdminRoute>(adminRoutes.map((route) => [route.menuCode, route]));
export function getRouteByMenuCode(menuCode: MenuCode | string) {
return routeByMenuCode.get(menuCode);
}

27
src/app/router/types.ts Normal file
View File

@ -0,0 +1,27 @@
import type { ComponentType } from "react";
import type { MenuCode, PermissionCode } from "@/app/permissions";
export type RouteLoader = () => Promise<ComponentType<any>>;
export interface PublicRoute {
label: string;
loader: RouteLoader;
pageKey: string;
path: string;
}
export interface AdminRoute extends PublicRoute {
menuCode: MenuCode;
permission: PermissionCode;
props?: Record<string, unknown>;
}
export interface NavItem {
children?: NavItem[];
code?: string;
icon?: ComponentType<{ fontSize?: "inherit" | "large" | "medium" | "small" }>;
id: string;
label: string;
path?: string;
permissionCode?: PermissionCode | string;
}

View File

@ -1,137 +0,0 @@
import Close from "@mui/icons-material/Close";
import PlayArrow from "@mui/icons-material/PlayArrow";
import Refresh from "@mui/icons-material/Refresh";
import Stop from "@mui/icons-material/Stop";
import { Button } from "../base/Button.jsx";
import { Card } from "../base/Card.jsx";
import { IconButton } from "../base/IconButton.jsx";
import { detailTabs } from "../../constants/status.js";
import { StatusBadge } from "./StatusBadge.jsx";
export function InlineInspector({ canOperate = true, restartService, service, setServiceStatus }) {
if (!service) {
return null;
}
return (
<Card className="inline-inspector" component="aside">
<PanelHead service={service} />
<StaticTabs />
<DetailBody activeTab={detailTabs[0]} service={service} />
<DrawerActions canOperate={canOperate} restartService={restartService} service={service} setServiceStatus={setServiceStatus} />
</Card>
);
}
export function DetailDrawer({ activeTab, canOperate = true, isOpen, onClose, restartService, service, setActiveTab, setServiceStatus }) {
if (!service) {
return null;
}
return (
<aside className={`drawer ${isOpen ? "drawer--open" : ""}`} aria-hidden={!isOpen}>
<div className="drawer-head">
<PanelTitle service={service} />
<IconButton label="关闭" onClick={onClose}>
<Close fontSize="small" />
</IconButton>
</div>
<div className="drawer-tabs">
{detailTabs.map((tab) => (
<button
className={`drawer-tab ${activeTab === tab ? "drawer-tab--active" : ""}`}
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
>
{tab}
</button>
))}
</div>
<DetailBody activeTab={activeTab} service={service} />
<DrawerActions canOperate={canOperate} restartService={restartService} service={service} setServiceStatus={setServiceStatus} />
</aside>
);
}
function PanelHead({ service }) {
return (
<div className="drawer-head">
<PanelTitle service={service} />
</div>
);
}
function PanelTitle({ service }) {
return (
<div className="panel-title-line">
<h2 className="drawer-title">{service.name}</h2>
<StatusBadge service={service} />
</div>
);
}
function StaticTabs() {
return (
<div className="drawer-tabs">
{detailTabs.map((tab, index) => (
<button className={`drawer-tab ${index === 0 ? "drawer-tab--active" : ""}`} key={tab} type="button">
{tab}
</button>
))}
</div>
);
}
function DetailBody({ activeTab, service }) {
return (
<div className="drawer-body">
{activeTab === "日志" ? (
<ul className="log-list">
{service.logs.map((log) => (
<li key={log}>{log}</li>
))}
</ul>
) : (
<div className="detail-grid">
<DetailRow label="服务名称" value={service.name} />
<DetailRow label="服务类型" value={service.desc} />
<DetailRow label="部署分组" value={service.deploy} />
<DetailRow label="版本" value={service.version} />
<DetailRow label="端口" value={service.port} />
<DetailRow label="副本" value={service.replicas} />
<DetailRow label="响应时间" value={service.response} />
<DetailRow label="更新时间" value={service.updated} />
</div>
)}
</div>
);
}
function DetailRow({ label, value }) {
return (
<div className="detail-row">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function DrawerActions({ canOperate, restartService, service, setServiceStatus }) {
return (
<div className="drawer-actions">
<Button disabled={!canOperate || service.status === "running"} variant="success" onClick={() => setServiceStatus(service.id, "running")}>
<PlayArrow fontSize="small" />
启动
</Button>
<Button disabled={!canOperate || service.status === "stopped"} onClick={() => restartService(service.id)}>
<Refresh fontSize="small" />
重启
</Button>
<Button disabled={!canOperate || service.status === "stopped"} variant="danger" onClick={() => setServiceStatus(service.id, "stopped")}>
<Stop fontSize="small" />
停止
</Button>
</div>
);
}

View File

@ -1,12 +0,0 @@
export function ResourceCell({ memory = false, value }) {
return (
<div className="service-cell resource-cell">
<div className="resource-line">
<span>{value}%</span>
</div>
<div className={`progress ${memory ? "progress--memory" : ""}`}>
<span style={{ width: `${Math.max(0, Math.min(100, value))}%` }} />
</div>
</div>
);
}

View File

@ -1,44 +0,0 @@
import MoreHoriz from "@mui/icons-material/MoreHoriz";
import PlayArrow from "@mui/icons-material/PlayArrow";
import Refresh from "@mui/icons-material/Refresh";
import Stop from "@mui/icons-material/Stop";
import { IconButton } from "../base/IconButton.jsx";
export function RowActions({ canOperate = true, restartService, service, setServiceStatus }) {
const isRunning = service.status === "running";
const isStopped = service.status === "stopped";
return (
<div className="row-actions" onClick={(event) => event.stopPropagation()}>
<IconButton
className="action-button action-button--success"
disabled={!canOperate || isRunning}
onClick={() => setServiceStatus(service.id, "running")}
label="启动"
tone="success"
>
<PlayArrow fontSize="inherit" />
</IconButton>
<IconButton
className="action-button"
disabled={!canOperate || isStopped}
onClick={() => restartService(service.id)}
label="重启"
>
<Refresh fontSize="inherit" />
</IconButton>
<IconButton
className="action-button action-button--danger"
disabled={!canOperate || isStopped}
onClick={() => setServiceStatus(service.id, "stopped")}
label="停止"
tone="danger"
>
<Stop fontSize="inherit" />
</IconButton>
<IconButton className="action-button" label="更多">
<MoreHoriz fontSize="inherit" />
</IconButton>
</div>
);
}

View File

@ -1,27 +0,0 @@
import { Card } from "../base/Card.jsx";
import { StatusBadge } from "./StatusBadge.jsx";
export function ServerRail({ openDetail, services }) {
return (
<Card className="server-rail" component="aside">
<div className="table-toolbar">
<div className="card-title">服务器列表</div>
<button className="link-button" type="button">
全部
</button>
</div>
<div className="server-list">
{services.slice(0, 4).map((service) => (
<button className="server-item" key={service.id} type="button" onClick={() => openDetail(service.id)}>
<span className={`server-status server-status--${service.status}`} />
<span>
<strong>{service.deploy}</strong>
<small>{service.name}</small>
</span>
<StatusBadge service={service} />
</button>
))}
</div>
</Card>
);
}

View File

@ -1,55 +0,0 @@
import Storage from "@mui/icons-material/Storage";
import { Card } from "../base/Card.jsx";
import { ResourceCell } from "./ResourceCell.jsx";
import { RowActions } from "./RowActions.jsx";
import { StatusBadge } from "./StatusBadge.jsx";
export function ServiceTable({ canOperate = true, openDetail, restartService, services, setServiceStatus, title }) {
return (
<Card className="table-card" component="section">
<div className="table-toolbar">
<div className="card-title">{title}</div>
<div className="muted"> {services.length} </div>
</div>
<div className="table-scroll">
<div className="service-table">
<div className="service-row service-row--head">
<div>服务名称</div>
<div>状态</div>
<div>部署</div>
<div>CPU</div>
<div>内存</div>
<div>操作</div>
</div>
{services.length ? (
services.map((service) => (
<div className="service-row" key={service.id} onClick={() => openDetail(service.id)} role="button" tabIndex={0}>
<div className="service-cell service-main">
<div className="service-icon">
<Storage fontSize="small" />
</div>
<div>
<div className="service-name">{service.name}</div>
<div className="service-desc">{service.desc}</div>
</div>
</div>
<div className="service-cell">
<StatusBadge service={service} />
</div>
<div className="service-cell deploy-cell">
{service.deploy}
<div className="muted">{service.version}</div>
</div>
<ResourceCell value={service.cpu} />
<ResourceCell value={service.memory} memory />
<RowActions canOperate={canOperate} service={service} restartService={restartService} setServiceStatus={setServiceStatus} />
</div>
))
) : (
<div className="empty-state">暂无匹配服务</div>
)}
</div>
</div>
</Card>
);
}

View File

@ -1,16 +0,0 @@
import Chip from "@mui/material/Chip";
import { statusMeta } from "../../constants/status.js";
export function StatusBadge({ service }) {
const meta = statusMeta[service.status];
return (
<Chip
className={`status-badge status-badge--${meta.className}`}
icon={<span className="status-point" />}
label={service.statusLabel}
size="small"
variant="outlined"
/>
);
}

View File

@ -1,148 +0,0 @@
import EditOutlined from "@mui/icons-material/EditOutlined";
import LockOutlined from "@mui/icons-material/LockOutlined";
import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
import Person from "@mui/icons-material/Person";
import PersonRemoveOutlined from "@mui/icons-material/PersonRemoveOutlined";
import VerifiedUserOutlined from "@mui/icons-material/VerifiedUserOutlined";
import Checkbox from "@mui/material/Checkbox";
import { Card } from "../base/Card.jsx";
import { IconButton } from "../base/IconButton.jsx";
import { UserStatusBadge } from "./UserStatusBadge.jsx";
export function UserTable({
canEdit,
canResetPassword,
canStatus,
onEdit,
onResetPassword,
onToggleDisabled,
onToggleLocked,
selectedIds,
setSelectedIds,
total,
users
}) {
const allChecked = users.length > 0 && users.every((user) => selectedIds.includes(user.id));
const toggleAll = (checked) => {
if (!checked) {
setSelectedIds([]);
return;
}
setSelectedIds(users.map((user) => user.id));
};
return (
<Card className="table-card user-table-card" component="section">
<div className="table-toolbar">
<div className="card-title">用户清单</div>
<div className="muted"> {total} </div>
</div>
<div className="table-scroll">
<div className="user-table user-table--selectable">
<div className="user-row user-row--head">
<div>
<Checkbox checked={allChecked} onChange={(event) => toggleAll(event.target.checked)} size="small" />
</div>
<div>用户</div>
<div>角色</div>
<div>团队</div>
<div>状态</div>
<div>MFA</div>
<div>最后登录</div>
<div>操作</div>
</div>
{users.length ? (
users.map((user) => (
<div className="user-row" key={user.id}>
<div>
<Checkbox
checked={selectedIds.includes(user.id)}
onChange={(event) => {
setSelectedIds((current) =>
event.target.checked ? [...current, user.id] : current.filter((id) => id !== user.id)
);
}}
size="small"
/>
</div>
<div className="user-cell user-main">
<div className="user-avatar">
<Person fontSize="small" />
</div>
<div>
<div className="service-name">{user.name}</div>
<div className="service-desc">{user.account}</div>
</div>
</div>
<div className="user-cell">{user.role || "-"}</div>
<div className="user-cell">{user.team || "-"}</div>
<div className="user-cell">
<UserStatusBadge status={user.status} />
</div>
<div className="user-cell">{user.mfa}</div>
<div className="user-cell muted">{user.lastLogin}</div>
<UserRowActions
canEdit={canEdit}
canResetPassword={canResetPassword}
canStatus={canStatus}
onEdit={onEdit}
onResetPassword={onResetPassword}
onToggleDisabled={onToggleDisabled}
onToggleLocked={onToggleLocked}
user={user}
/>
</div>
))
) : (
<div className="empty-state">暂无匹配用户</div>
)}
</div>
</div>
</Card>
);
}
function UserRowActions({
canEdit,
canResetPassword,
canStatus,
onEdit,
onResetPassword,
onToggleDisabled,
onToggleLocked,
user
}) {
const isDisabled = user.status === "disabled";
const isLocked = user.status === "locked";
return (
<div className="row-actions">
<IconButton className="action-button" disabled={!canEdit} label="编辑" onClick={() => onEdit(user)}>
<EditOutlined fontSize="inherit" />
</IconButton>
<IconButton className="action-button" disabled={!canResetPassword} label="重置密码" onClick={() => onResetPassword(user)}>
<PasswordOutlined fontSize="inherit" />
</IconButton>
<IconButton
className="action-button"
disabled={!canStatus || isDisabled}
label={isLocked ? "解锁" : "锁定"}
onClick={() => onToggleLocked(user)}
tone={isLocked ? "success" : undefined}
>
{isLocked ? <LockOpenOutlined fontSize="inherit" /> : <LockOutlined fontSize="inherit" />}
</IconButton>
<IconButton
className={`action-button ${isDisabled ? "action-button--success" : "action-button--danger"}`}
disabled={!canStatus}
label={isDisabled ? "启用" : "停用"}
onClick={() => onToggleDisabled(user)}
tone={isDisabled ? "success" : "danger"}
>
{isDisabled ? <VerifiedUserOutlined fontSize="inherit" /> : <PersonRemoveOutlined fontSize="inherit" />}
</IconButton>
</div>
);
}

View File

@ -1,105 +0,0 @@
import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import LoginOutlined from "@mui/icons-material/LoginOutlined";
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
import NotificationsNoneOutlined from "@mui/icons-material/NotificationsNoneOutlined";
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicationsOutlined";
export const navItems = [
{ id: "overview", code: "overview", label: "总览", path: "/overview", icon: DashboardOutlined },
{ id: "services", code: "services", label: "服务管理", path: "/services", icon: Inventory2Outlined },
{
id: "system",
code: "system",
label: "系统管理",
icon: SettingsApplicationsOutlined,
children: [
{ id: "system-users", code: "system-users", label: "用户管理", path: "/system/users", icon: ManageAccountsOutlined },
{ id: "system-roles", code: "system-roles", label: "角色配置", path: "/system/roles", icon: ShieldOutlined },
{ id: "system-menus", code: "system-menus", label: "菜单权限", path: "/system/menus", icon: MenuOpenOutlined }
]
},
{
id: "logs",
code: "logs",
label: "日志审计",
icon: HistoryOutlined,
children: [
{ id: "logs-login", code: "logs-login", label: "登录日志", path: "/logs/login", icon: LoginOutlined },
{ id: "logs-operations", code: "logs-operations", label: "操作日志", path: "/logs/operations", icon: ReceiptLongOutlined }
]
},
{
id: "notifications",
code: "notifications",
label: "通知中心",
path: "/notifications",
icon: NotificationsNoneOutlined
}
];
const iconMap = {
dashboard: DashboardOutlined,
history: HistoryOutlined,
inventory: Inventory2Outlined,
login: LoginOutlined,
menu: MenuOpenOutlined,
notifications: NotificationsNoneOutlined,
receipt: ReceiptLongOutlined,
settings: SettingsApplicationsOutlined,
shield: ShieldOutlined,
users: ManageAccountsOutlined
};
export function mapBackendMenus(menus = []) {
return menus.map((item) => ({
id: item.code || String(item.id),
code: item.code,
label: item.label,
path: item.path,
icon: iconMap[item.icon] || MenuOpenOutlined,
permissionCode: item.permissionCode,
children: item.children?.length ? mapBackendMenus(item.children) : undefined
}));
}
export function findNavItemById(id, items = navItems) {
for (const item of items) {
if (item.id === id) {
return item;
}
if (item.children?.length) {
const child = findNavItemById(id, item.children);
if (child) {
return child;
}
}
}
return null;
}
export function findNavItemByPath(pathname, items = navItems) {
let fallback = null;
for (const item of items) {
if (item.path && pathname.startsWith(item.path)) {
fallback = item;
}
if (item.children?.length) {
const child = findNavItemByPath(pathname, item.children);
if (child) {
return child;
}
}
}
return fallback;
}

View File

@ -1,8 +0,0 @@
export const statusMeta = {
running: { label: "运行中", className: "running" },
warning: { label: "警告", className: "warning" },
error: { label: "异常", className: "error" },
stopped: { label: "已停止", className: "stopped" }
};
export const detailTabs = ["基础信息", "配置", "监控", "日志"];

View File

@ -1,108 +0,0 @@
export const initialServices = [
{
id: "svc-gateway",
name: "API Gateway",
desc: "流量入口 / 鉴权 / 限流",
status: "running",
statusLabel: "运行中",
cpu: 36,
memory: 58,
deploy: "prod-a",
version: "v2.8.4",
port: "8080",
replicas: "6/6",
response: "42ms",
updated: "14:22",
trend: [34, 38, 35, 42, 39, 44, 36],
logs: ["14:22 健康检查通过", "14:18 配置热更新完成", "13:55 副本扩容至 6"]
},
{
id: "svc-auth",
name: "Auth Service",
desc: "账号 / 权限 / Token",
status: "warning",
statusLabel: "警告",
cpu: 68,
memory: 74,
deploy: "prod-a",
version: "v1.14.0",
port: "7001",
replicas: "4/4",
response: "96ms",
updated: "14:19",
trend: [48, 52, 55, 62, 71, 66, 68],
logs: ["14:19 P95 延迟超过阈值", "14:07 慢查询 8 条", "13:42 健康检查通过"]
},
{
id: "svc-order",
name: "Order Worker",
desc: "订单队列 / 任务分发",
status: "running",
statusLabel: "运行中",
cpu: 52,
memory: 63,
deploy: "prod-b",
version: "v3.2.1",
port: "9100",
replicas: "8/8",
response: "61ms",
updated: "14:21",
trend: [41, 44, 46, 49, 52, 56, 52],
logs: ["14:21 消费速率 2.4k/min", "14:10 队列积压清零", "13:36 副本滚动更新完成"]
},
{
id: "svc-billing",
name: "Billing Core",
desc: "计费 / 对账 / 回调",
status: "error",
statusLabel: "异常",
cpu: 84,
memory: 81,
deploy: "prod-b",
version: "v2.4.9",
port: "7300",
replicas: "2/4",
response: "318ms",
updated: "14:16",
trend: [56, 64, 73, 78, 82, 89, 84],
logs: ["14:16 2 个副本健康检查失败", "14:12 回调超时率 6.3%", "13:58 自动重试已触发"]
},
{
id: "svc-cache",
name: "Redis Cluster",
desc: "缓存 / 分布式锁",
status: "running",
statusLabel: "运行中",
cpu: 29,
memory: 47,
deploy: "infra",
version: "v7.2.5",
port: "6379",
replicas: "3/3",
response: "5ms",
updated: "14:23",
trend: [24, 28, 27, 31, 29, 32, 29],
logs: ["14:23 主从延迟 8ms", "14:00 快照完成", "13:30 内存碎片率正常"]
},
{
id: "svc-search",
name: "Search Indexer",
desc: "索引构建 / 查询同步",
status: "stopped",
statusLabel: "已停止",
cpu: 0,
memory: 12,
deploy: "staging",
version: "v0.9.7",
port: "9200",
replicas: "0/2",
response: "-",
updated: "13:45",
trend: [23, 18, 14, 8, 0, 0, 0],
logs: ["13:45 手动停止", "13:40 索引任务完成", "13:20 灰度环境同步完成"]
}
];
export const trafficSeries = [36, 42, 39, 54, 49, 62, 58, 73, 68, 76, 71, 82];
export const cpuSeries = [42, 44, 48, 52, 50, 57, 61, 58, 64, 69, 66, 72];
export const alertSeries = [4, 5, 3, 8, 6, 9, 7, 12, 10, 14, 11, 9];

View File

@ -0,0 +1,18 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiList, H5LinkConfigDto, H5LinkConfigUpdatePayload } from "@/shared/api/types";
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
const endpoint = API_ENDPOINTS.listH5Links;
return apiRequest<ApiList<H5LinkConfigDto>>(apiEndpointPath(API_OPERATIONS.listH5Links), {
method: endpoint.method
});
}
export function updateH5Links(payload: H5LinkConfigUpdatePayload): Promise<ApiList<H5LinkConfigDto>> {
const endpoint = API_ENDPOINTS.updateH5Links;
return apiRequest<ApiList<H5LinkConfigDto>, H5LinkConfigUpdatePayload>(apiEndpointPath(API_OPERATIONS.updateH5Links), {
body: payload,
method: endpoint.method
});
}

View File

@ -0,0 +1,7 @@
.linkText {
overflow: hidden;
max-width: 100%;
color: var(--text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -0,0 +1,9 @@
export const H5_LINK_KEYS = [
"host-center",
"bd-center",
"bd-leader-center",
"agency-center",
"invite-user"
] as const;
export type H5LinkKey = (typeof H5_LINK_KEYS)[number];

View File

@ -0,0 +1,68 @@
import { useCallback, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { listH5Links, updateH5Links } from "@/features/app-config/api";
import { useAppConfigAbilities } from "@/features/app-config/permissions.js";
import { h5LinkUpdateSchema } from "@/features/app-config/schema";
const emptyData = { items: [], total: 0 };
const emptyForm = () => ({ url: "" });
export function useH5ConfigPage() {
const abilities = useAppConfigAbilities();
const { showToast } = useToast();
const [editingItem, setEditingItem] = useState(null);
const [form, setForm] = useState(emptyForm);
const [loadingAction, setLoadingAction] = useState("");
const queryFn = useCallback(() => listH5Links(), []);
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
errorMessage: "加载 H5 配置失败",
initialData: emptyData,
queryKey: ["app-config", "h5-links"]
});
const openEdit = (item) => {
setEditingItem(item);
setForm({ url: item.url || "" });
};
const closeEdit = () => {
setEditingItem(null);
setForm(emptyForm());
};
const submitEdit = async (event) => {
event.preventDefault();
if (!editingItem) {
return;
}
const payload = parseForm(h5LinkUpdateSchema, { key: editingItem.key, url: form.url });
setLoadingAction("edit");
try {
await updateH5Links({ items: [payload] });
closeEdit();
await reload();
showToast("H5配置已更新", "success");
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
closeEdit,
data,
editingItem,
error,
form,
loading,
loadingAction,
openEdit,
reload,
setForm,
submitEdit
};
}

View File

@ -0,0 +1,97 @@
import EditOutlined from "@mui/icons-material/EditOutlined";
import LinkOutlined from "@mui/icons-material/LinkOutlined";
import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js";
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminRowActions
} from "@/shared/ui/AdminListLayout.jsx";
import { useH5ConfigPage } from "@/features/app-config/hooks/useH5ConfigPage.js";
import styles from "@/features/app-config/app-config.module.css";
const baseColumns = [
{
key: "label",
label: "配置项",
width: "minmax(180px, 0.8fr)"
},
{
key: "key",
label: "Key",
width: "minmax(200px, 0.9fr)"
},
{
key: "url",
label: "H5链接",
render: (item) => <span className={styles.linkText}>{item.url || "-"}</span>,
width: "minmax(360px, 1.8fr)"
},
{
key: "updatedAtMs",
label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(170px, 0.8fr)"
}
];
export function H5ConfigPage() {
const page = useH5ConfigPage();
const items = page.data.items || [];
const columns = page.abilities.canUpdate
? [
...baseColumns,
{
key: "actions",
label: "操作",
render: (item) => <H5LinkActions item={item} page={page} />,
width: "minmax(88px, 0.4fr)"
}
]
: baseColumns;
return (
<AdminListPage>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable columns={columns} items={items} minWidth="980px" rowKey={(item) => item.key} />
<div className="pagination-bar">
<span>{items.length} </span>
</div>
</AdminListBody>
</DataState>
<AdminFormDialog
loading={page.loadingAction === "edit"}
open={Boolean(page.editingItem)}
size="compact"
submitDisabled={!page.abilities.canUpdate || page.loadingAction === "edit"}
title="编辑H5链接"
onClose={page.closeEdit}
onSubmit={page.submitEdit}
>
<TextField
autoFocus
disabled={!page.abilities.canUpdate}
label={page.editingItem?.label || "H5链接"}
value={page.form.url}
onChange={(event) => page.setForm({ url: event.target.value })}
/>
</AdminFormDialog>
</AdminListPage>
);
}
function H5LinkActions({ item, page }) {
return (
<AdminRowActions>
<AdminActionIconButton label="编辑" onClick={() => page.openEdit(item)}>
{item.url ? <LinkOutlined fontSize="small" /> : <EditOutlined fontSize="small" />}
</AdminActionIconButton>
</AdminRowActions>
);
}

View File

@ -0,0 +1,11 @@
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function useAppConfigAbilities() {
const { can } = useAuth();
return {
canUpdate: can(PERMISSIONS.appConfigUpdate),
canView: can(PERMISSIONS.appConfigView)
};
}

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const appConfigRoutes = [
{
label: "H5配置",
loader: () => import("./pages/H5ConfigPage.jsx").then((module) => module.H5ConfigPage),
menuCode: MENU_CODES.appConfigH5,
pageKey: "app-config-h5",
path: "/app-config/h5",
permission: PERMISSIONS.appConfigView
}
];

View File

@ -0,0 +1,15 @@
import { z } from "zod";
import { H5_LINK_KEYS } from "@/features/app-config/constants";
const h5LinkURLSchema = z.string()
.trim()
.max(2048, "H5 链接不能超过 2048 个字符")
.refine((value) => !/\s/.test(value), "H5 链接不能包含空白字符")
.default("");
export const h5LinkUpdateSchema = z.object({
key: z.enum(H5_LINK_KEYS),
url: h5LinkURLSchema
});
export type H5LinkUpdateForm = z.infer<typeof h5LinkUpdateSchema>;

View File

@ -0,0 +1,10 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiList, AdminAppDto } from "@/shared/api/types";
export function listAdminApps(): Promise<ApiList<AdminAppDto>> {
const endpoint = API_ENDPOINTS.listApps;
return apiRequest<ApiList<AdminAppDto>>(apiEndpointPath(API_OPERATIONS.listApps), {
method: endpoint.method
});
}

View File

@ -0,0 +1,51 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type {
ApiPage,
AppUserDto,
AppUserPasswordPayload,
AppUserUpdatePayload,
EntityId,
PageQuery
} from "@/shared/api/types";
export function listAppUsers(query: PageQuery = {}): Promise<ApiPage<AppUserDto>> {
const endpoint = API_ENDPOINTS.appListUsers;
return apiRequest<ApiPage<AppUserDto>>(apiEndpointPath(API_OPERATIONS.appListUsers), {
method: endpoint.method,
query
});
}
export function updateAppUser(userId: EntityId, payload: AppUserUpdatePayload): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appUpdateUser;
return apiRequest<AppUserDto, AppUserUpdatePayload>(apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }), {
body: payload,
method: endpoint.method
});
}
export function banAppUser(userId: EntityId): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appBanUser;
return apiRequest<AppUserDto>(apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }), {
method: endpoint.method
});
}
export function unbanAppUser(userId: EntityId): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appUnbanUser;
return apiRequest<AppUserDto>(apiEndpointPath(API_OPERATIONS.appUnbanUser, { id: userId }), {
method: endpoint.method
});
}
export function setAppUserPassword(userId: EntityId, payload: AppUserPasswordPayload): Promise<{ passwordSet: boolean }> {
const endpoint = API_ENDPOINTS.appSetPassword;
return apiRequest<{ passwordSet: boolean }, AppUserPasswordPayload>(
apiEndpointPath(API_OPERATIONS.appSetPassword, { id: userId }),
{
body: payload,
method: endpoint.method
}
);
}

View File

@ -0,0 +1,200 @@
.root {
display: flex;
height: calc(100vh - var(--header-height) - var(--space-8) - var(--space-4));
height: calc(100dvh - var(--header-height) - var(--space-8) - var(--space-4));
min-height: 0;
flex-direction: column;
}
.contentPanel {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card);
}
.contentPanel :global(.data-state),
.contentPanel :global(.table-frame) {
flex: 1;
min-height: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.contentPanel :global(.table-scroll) {
overflow: auto;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.filters {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.search {
flex: 0 1 320px;
}
.statusSelect {
flex: 0 0 150px;
}
.listBlock {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
background: var(--bg-card);
}
.listBlock :global(.pagination-bar) {
margin: var(--space-3) var(--space-5) var(--space-4);
}
.identity {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.avatar {
display: inline-flex;
width: 36px;
height: 36px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--bg-card-strong);
color: var(--text-secondary);
font-weight: 700;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.identityText {
min-width: 0;
}
.nameLine {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
color: var(--text-primary);
font-weight: 650;
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gender {
display: inline-flex;
width: 18px;
height: 18px;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: 700;
line-height: 1;
}
.genderMale {
background: rgba(37, 99, 235, 0.1);
color: #2563eb;
}
.genderFemale {
background: rgba(219, 39, 119, 0.12);
color: #db2777;
}
.genderUnknown {
background: var(--neutral-surface);
color: var(--text-tertiary);
}
.meta {
color: var(--text-tertiary);
font-size: var(--admin-font-size);
}
.stack {
display: grid;
gap: 2px;
}
.primaryText {
color: var(--text-primary);
font-weight: 650;
}
.countryButton {
display: inline-flex;
width: fit-content;
max-width: 100%;
align-items: center;
justify-content: flex-start;
overflow: hidden;
border: 0;
background: transparent;
color: var(--primary);
cursor: pointer;
font: inherit;
font-weight: 650;
padding: 0;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.countryButton:hover {
color: var(--primary-strong);
}
.rowActions {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
@media (max-width: 760px) {
.toolbar,
.filters {
align-items: stretch;
flex-direction: column;
}
.search,
.statusSelect {
flex: 1 1 auto;
width: 100%;
}
}

View File

@ -0,0 +1,18 @@
export const appUserStatusFilters = [
["", "全部"],
["active", "正常"],
["banned", "封禁"]
];
export const appUserStatusLabels = {
active: "正常",
banned: "封禁",
disabled: "封禁"
};
export const genderOptions = [
["", "未设置"],
["male", "男"],
["female", "女"],
["unknown", "未知"]
];

View File

@ -0,0 +1,197 @@
import { useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
banAppUser,
listAppUsers,
setAppUserPassword,
unbanAppUser,
updateAppUser
} from "@/features/app-users/api";
import { useAppUserAbilities } from "@/features/app-users/permissions.js";
import { appUserCountrySchema, appUserPasswordSchema, appUserUpdateSchema } from "@/features/app-users/schema";
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
const pageSize = 10;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyEditForm = () => ({ avatar: "", gender: "", username: "" });
const emptyCountryForm = () => ({ country: "" });
const emptyPasswordForm = () => ({ password: "" });
export function useAppUsersPage() {
const abilities = useAppUserAbilities();
const confirm = useConfirm();
const { showToast } = useToast();
const { countryOptions, loadingCountries } = useCountryOptions();
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const [activeAction, setActiveAction] = useState("");
const [activeUser, setActiveUser] = useState(null);
const [editForm, setEditForm] = useState(emptyEditForm);
const [countryForm, setCountryForm] = useState(emptyCountryForm);
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(
() => ({
keyword: query,
status
}),
[query, status]
);
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
errorMessage: "加载用户列表失败",
fetcher: listAppUsers,
filters,
page,
pageSize,
queryKey: ["app-users", filters, page]
});
const changeQuery = (value) => {
setQuery(value);
setPage(1);
};
const changeStatus = (value) => {
setStatus(value);
setPage(1);
};
const openEdit = (user) => {
setActiveUser(user);
setEditForm({
avatar: user.avatar || "",
gender: user.gender || "",
username: user.username || ""
});
setActiveAction("edit");
};
const openPassword = (user) => {
setActiveUser(user);
setPasswordForm(emptyPasswordForm());
setActiveAction("password");
};
const openCountry = (user) => {
setActiveUser(user);
setCountryForm({ country: user.country || "" });
setActiveAction("country");
};
const closeAction = () => {
setActiveAction("");
setActiveUser(null);
};
const submitEdit = async (event) => {
event.preventDefault();
if (!activeUser) {
return;
}
await runAction("edit", "用户已更新", async () => {
const payload = parseForm(appUserUpdateSchema, editForm);
await updateAppUser(activeUser.userId, payload);
closeAction();
await reload();
});
};
const submitCountry = async (event) => {
event.preventDefault();
if (!activeUser) {
return;
}
await runAction("country", "国家已更新", async () => {
const payload = parseForm(appUserCountrySchema, countryForm);
await updateAppUser(activeUser.userId, payload);
closeAction();
await reload();
});
};
const submitPassword = async (event) => {
event.preventDefault();
if (!activeUser) {
return;
}
await runAction("password", "密码已设置", async () => {
const payload = parseForm(appUserPasswordSchema, passwordForm);
await setAppUserPassword(activeUser.userId, payload);
closeAction();
});
};
const toggleBan = async (user) => {
const banned = isBanned(user);
const ok = await confirm({
confirmText: banned ? "解封" : "封禁",
message: `${user.username || user.displayUserId || user.userId} 的状态会立即变更。`,
title: banned ? "解封用户" : "封禁用户",
tone: banned ? "primary" : "danger"
});
if (!ok) {
return;
}
await runAction(`status-${user.userId}`, banned ? "用户已解封" : "用户已封禁", async () => {
if (banned) {
await unbanAppUser(user.userId);
} else {
await banAppUser(user.userId);
}
await reload();
});
};
const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action);
try {
await submitter();
showToast(successMessage, "success");
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
activeUser,
changeQuery,
changeStatus,
closeAction,
countryForm,
countryOptions,
data,
editForm,
error,
loading,
loadingAction,
loadingCountries,
openCountry,
openEdit,
openPassword,
page,
passwordForm,
query,
reload,
setCountryForm,
setEditForm,
setPage,
setPasswordForm,
status,
submitCountry,
submitEdit,
submitPassword,
toggleBan
};
}
function isBanned(user) {
return user.status === "banned" || user.status === "disabled";
}

View File

@ -0,0 +1,266 @@
import BlockOutlined from "@mui/icons-material/BlockOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import Autocomplete from "@mui/material/Autocomplete";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import {
appUserStatusFilters,
appUserStatusLabels,
genderOptions
} from "@/features/app-users/constants.js";
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
import styles from "@/features/app-users/app-users.module.css";
const columns = [
{
key: "identity",
label: "用户",
width: "minmax(240px, 1.5fr)",
render: (user) => <UserIdentity user={user} />
},
{ key: "coin", label: "金币", width: "minmax(90px, 0.6fr)", render: (user) => formatNumber(user.coin) },
{ key: "diamond", label: "钻石", width: "minmax(90px, 0.6fr)", render: (user) => formatNumber(user.diamond) },
{
key: "status",
label: "状态",
width: "minmax(92px, 0.6fr)",
render: (user) => <UserStatus status={user.status} />
},
{
key: "time",
label: "创建 / 活跃",
width: "minmax(180px, 1fr)",
render: (user) => (
<div className={styles.stack}>
<span>{formatMillis(user.createdAtMs)}</span>
<span className={styles.meta}>{formatMillis(user.lastActiveAtMs)}</span>
</div>
)
}
];
export function AppUserListPage() {
const page = useAppUsersPage();
const items = page.data.items || [];
const total = page.data.total || 0;
const tableColumns = [
columns[0],
{
key: "location",
label: "国家 / 区域",
width: "minmax(170px, 1fr)",
render: (user) => <UserLocation page={page} user={user} />
},
...columns.slice(1),
{
key: "actions",
label: "操作",
width: "minmax(148px, 0.8fr)",
render: (user) => <UserActions page={page} user={user} />
}
];
return (
<section className={styles.root}>
<div className={styles.contentPanel}>
<div className={styles.toolbar}>
<section className={styles.filters}>
<SearchBox className={styles.search} value={page.query} onChange={page.changeQuery} placeholder="搜索名称、短 ID、用户 ID..." />
<StatusSelect
className={styles.statusSelect}
options={appUserStatusFilters}
value={page.status}
onChange={page.changeStatus}
/>
</section>
</div>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}>
<DataTable columns={tableColumns} items={items} minWidth="1220px" rowKey={(user) => user.userId} />
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
</div>
</DataState>
</div>
<ActionModal
disabled={!page.abilities.canUpdate}
loading={page.loadingAction === "edit"}
open={page.activeAction === "edit"}
title="编辑用户"
onClose={page.closeAction}
onSubmit={page.submitEdit}
>
<TextField disabled={!page.abilities.canUpdate} label="用户名称" value={page.editForm.username} onChange={(event) => page.setEditForm({ ...page.editForm, username: event.target.value })} />
<UploadField disabled={!page.abilities.canUpdate || !page.abilities.canUpload} label="头像" value={page.editForm.avatar} onChange={(avatar) => page.setEditForm({ ...page.editForm, avatar })} />
<TextField disabled={!page.abilities.canUpdate} label="性别" select value={page.editForm.gender} onChange={(event) => page.setEditForm({ ...page.editForm, gender: event.target.value })}>
{genderOptions.map(([value, label]) => (
<MenuItem key={value || "empty"} value={value}>{label}</MenuItem>
))}
</TextField>
</ActionModal>
<ActionModal
disabled={!page.abilities.canUpdate}
loading={page.loadingAction === "country"}
open={page.activeAction === "country"}
title="修改国家"
onClose={page.closeAction}
onSubmit={page.submitCountry}
>
<Autocomplete
disabled={!page.abilities.canUpdate || page.loadingCountries}
getOptionLabel={(option) => option.label || ""}
isOptionEqualToValue={(option, value) => option.value === value.value}
loading={page.loadingCountries}
options={page.countryOptions}
renderInput={(params) => <TextField {...params} label="国家" required />}
size="small"
value={page.countryOptions.find((option) => option.value === page.countryForm.country) || null}
onChange={(_, option) => page.setCountryForm({ country: option?.value || "" })}
/>
</ActionModal>
<ActionModal
disabled={!page.abilities.canPassword}
loading={page.loadingAction === "password"}
open={page.activeAction === "password"}
title="设置密码"
onClose={page.closeAction}
onSubmit={page.submitPassword}
>
<TextField disabled={!page.abilities.canPassword} label="新密码" type="password" value={page.passwordForm.password} onChange={(event) => page.setPasswordForm({ ...page.passwordForm, password: event.target.value })} />
</ActionModal>
</section>
);
}
function UserLocation({ page, user }) {
const countryLabel = formatCountry(user);
const regionLabel = user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-");
return (
<div className={styles.stack}>
{page.abilities.canUpdate ? (
<button className={styles.countryButton} type="button" onClick={() => page.openCountry(user)}>
{countryLabel}
</button>
) : (
<span className={styles.primaryText}>{countryLabel}</span>
)}
<span className={styles.meta}>{regionLabel}</span>
</div>
);
}
function UserIdentity({ user }) {
const name = user.username || "-";
const shortId = user.displayUserId || user.userId;
const gender = genderView(user.gender);
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img src={user.avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<div className={styles.nameLine}>
<span className={styles.name}>{name}</span>
<span className={`${styles.gender} ${styles[gender.className]}`}>{gender.symbol}</span>
</div>
<div className={styles.meta}>{shortId}</div>
</div>
</div>
);
}
function UserActions({ page, user }) {
const banned = user.status === "banned" || user.status === "disabled";
return (
<div className={styles.rowActions}>
{page.abilities.canUpdate ? (
<Tooltip arrow title="编辑">
<span>
<IconButton label="编辑" onClick={() => page.openEdit(user)}>
<EditOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
) : null}
{page.abilities.canStatus ? (
<Tooltip arrow title={banned ? "解封" : "封禁"}>
<span>
<IconButton disabled={page.loadingAction === `status-${user.userId}`} label={banned ? "解封" : "封禁"} tone={banned ? "success" : "danger"} onClick={() => page.toggleBan(user)}>
{banned ? <LockOpenOutlined fontSize="small" /> : <BlockOutlined fontSize="small" />}
</IconButton>
</span>
</Tooltip>
) : null}
{page.abilities.canPassword ? (
<Tooltip arrow title="设置密码">
<span>
<IconButton label="设置密码" onClick={() => page.openPassword(user)}>
<PasswordOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
) : null}
</div>
);
}
function UserStatus({ status }) {
const tone = status === "active" ? "running" : "danger";
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{appUserStatusLabels[status] || status || "-"}
</span>
);
}
function ActionModal({ children, disabled, loading, onClose, onSubmit, open, title }) {
return (
<AdminFormDialog
loading={loading}
open={open}
size="compact"
submitDisabled={disabled || loading}
title={title}
onClose={onClose}
onSubmit={onSubmit}
>
{children}
</AdminFormDialog>
);
}
function genderView(gender) {
const value = String(gender || "").toLowerCase();
if (value === "male" || value === "m" || value === "男") {
return { className: "genderMale", symbol: "♂" };
}
if (value === "female" || value === "f" || value === "女") {
return { className: "genderFemale", symbol: "♀" };
}
return { className: "genderUnknown", symbol: "-" };
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}
function formatCountry(user) {
return user.countryDisplayName || user.countryName || user.country || "-";
}

View File

@ -0,0 +1,14 @@
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function useAppUserAbilities() {
const { can } = useAuth();
return {
canPassword: can(PERMISSIONS.appUserPassword),
canStatus: can(PERMISSIONS.appUserStatus),
canUpdate: can(PERMISSIONS.appUserUpdate),
canUpload: can(PERMISSIONS.uploadCreate),
canView: can(PERMISSIONS.appUserView)
};
}

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const appUserRoutes = [
{
label: "用户列表",
loader: () => import("./pages/AppUserListPage.jsx").then((module) => module.AppUserListPage),
menuCode: MENU_CODES.appUserList,
pageKey: "app-user-list",
path: "/app/users",
permission: PERMISSIONS.appUserView
}
];

View File

@ -0,0 +1,19 @@
import { z } from "zod";
export const appUserUpdateSchema = z.object({
avatar: z.string().trim().max(512, "头像不能超过 512 个字符").optional().default(""),
gender: z.string().trim().max(32, "性别不能超过 32 个字符").optional().default(""),
username: z.string().trim().max(64, "用户名称不能超过 64 个字符").optional().default("")
});
export const appUserCountrySchema = z.object({
country: z.string().trim().min(2, "请选择国家").max(16, "国家码不能超过 16 个字符")
});
export const appUserPasswordSchema = z.object({
password: z.string().trim().min(6, "密码至少 6 位").max(128, "密码不能超过 128 个字符")
});
export type AppUserUpdateForm = z.infer<typeof appUserUpdateSchema>;
export type AppUserCountryForm = z.infer<typeof appUserCountrySchema>;
export type AppUserPasswordForm = z.infer<typeof appUserPasswordSchema>;

37
src/features/auth/api.ts Normal file
View File

@ -0,0 +1,37 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ChangePasswordPayload, LoginPayload, SessionDto } from "@/shared/api/types";
export function login(payload: LoginPayload): Promise<SessionDto> {
const endpoint = API_ENDPOINTS.login;
return apiRequest<SessionDto, LoginPayload>(apiEndpointPath(API_OPERATIONS.login), {
body: payload,
method: endpoint.method,
skipAuth: true
});
}
export function logout(): Promise<unknown> {
const endpoint = API_ENDPOINTS.logout;
return apiRequest(apiEndpointPath(API_OPERATIONS.logout), { method: endpoint.method, skipAuth: true });
}
export function refreshSession(): Promise<SessionDto> {
const endpoint = API_ENDPOINTS.refresh;
return apiRequest<SessionDto>(apiEndpointPath(API_OPERATIONS.refresh), {
method: endpoint.method,
skipAuth: true
});
}
export function getMe(): Promise<SessionDto> {
return apiRequest<SessionDto>(apiEndpointPath(API_OPERATIONS.me));
}
export function changePassword(payload: ChangePasswordPayload): Promise<unknown> {
const endpoint = API_ENDPOINTS.changePassword;
return apiRequest<unknown, ChangePasswordPayload>(apiEndpointPath(API_OPERATIONS.changePassword), {
body: payload,
method: endpoint.method
});
}

View File

@ -3,9 +3,9 @@ import LoginOutlined from "@mui/icons-material/LoginOutlined";
import TextField from "@mui/material/TextField";
import { useState } from "react";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthProvider.jsx";
import { Button } from "../components/base/Button.jsx";
import { useToast } from "../components/base/ToastProvider.jsx";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
export function LoginPage() {
const [username, setUsername] = useState("admin");

View File

@ -0,0 +1,8 @@
export const authRoutes = [
{
label: "登录",
loader: () => import("./pages/LoginPage.jsx").then((module) => module.LoginPage),
pageKey: "login",
path: "/login"
}
];

View File

@ -0,0 +1,7 @@
import { apiRequest } from "@/shared/api/request";
import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { DashboardOverviewDto } from "@/shared/api/types";
export function getDashboardOverview(): Promise<DashboardOverviewDto> {
return apiRequest<DashboardOverviewDto>(apiEndpointPath(API_OPERATIONS.dashboardOverview));
}

View File

@ -0,0 +1,16 @@
export function DashboardAlerts({ alerts = [] }) {
return (
<section className="alert-strip" aria-label="实时告警">
{alerts.map((alert) => (
<div className="alert-item" key={`${alert.name}-${alert.time}`}>
<span className={`alert-dot alert-dot--${alert.type}`} />
<div>
<div className="alert-name">{alert.name}</div>
<div className="alert-desc">{alert.desc}</div>
</div>
<div className="alert-time">{alert.time}</div>
</div>
))}
</section>
);
}

View File

@ -0,0 +1,18 @@
import { Card } from "@/shared/ui/Card.jsx";
import { BarChart } from "@/shared/charts/BarChart.jsx";
import { ChartCard } from "@/shared/charts/ChartCard.jsx";
export function DashboardCharts({ overview }) {
return (
<section className="overview-charts" aria-label="资源图表">
<ChartCard title="后台用户" colorToken="--chart-blue" series={overview?.series?.users || []} />
<ChartCard title="操作日志" colorToken="--chart-green" series={overview?.series?.operations || []} />
<Card className="chart-card">
<div className="card-head">
<div className="card-title">通知趋势</div>
</div>
<BarChart series={overview?.series?.notifications || []} />
</Card>
</section>
);
}

View File

@ -0,0 +1,16 @@
import DeveloperBoardOutlined from "@mui/icons-material/DeveloperBoardOutlined";
import DnsOutlined from "@mui/icons-material/DnsOutlined";
import SecurityOutlined from "@mui/icons-material/SecurityOutlined";
import StorageOutlined from "@mui/icons-material/StorageOutlined";
import { KpiCard } from "@/shared/ui/KpiCard.jsx";
export function DashboardStats({ overview, stats }) {
return (
<section className="kpi-grid" aria-label="系统指标">
<KpiCard icon={DnsOutlined} label="后台用户" value={overview?.usersTotal || 0} unit="个" sub={<span className="status-ok">启用 {stats.activeUsers}</span>} />
<KpiCard icon={DeveloperBoardOutlined} label="角色数量" value={stats.rolesTotal} unit="个" sub={`菜单 ${stats.menuTotal}`} />
<KpiCard icon={StorageOutlined} label="今日操作" value={stats.logsToday} unit="条" tone="info" sub="操作日志" />
<KpiCard icon={SecurityOutlined} label="未读通知" value={stats.unreadNotifications} unit="条" tone="warning" sub={<><span className="status-warn">锁定 {stats.lockedUsers}</span> / 停用 {stats.disabledUsers}</>} />
</section>
);
}

View File

@ -0,0 +1,16 @@
import Refresh from "@mui/icons-material/Refresh";
import { Button } from "@/shared/ui/Button.jsx";
import { PageHead } from "@/shared/ui/PageHead.jsx";
import { TimeRangeSelect } from "@/shared/ui/TimeRangeSelect.jsx";
export function DashboardToolbar({ onRefresh, onRangeChange, overview, range }) {
return (
<PageHead title="系统概览" meta={`刷新时间 ${overview?.updatedAt || "-"}`}>
<TimeRangeSelect value={range} onChange={onRangeChange} />
<Button onClick={onRefresh}>
<Refresh fontSize="small" />
刷新
</Button>
</PageHead>
);
}

View File

@ -0,0 +1,360 @@
.overview-charts {
display: grid;
grid-template-columns: repeat(3, minmax(260px, 1fr));
gap: var(--space-4);
margin-top: var(--space-4);
}
.chart-card {
min-height: 304px;
padding: var(--space-5);
animation: panel-enter var(--motion-slow) var(--ease-emphasized) both;
transition:
border-color var(--motion-base) var(--ease-standard),
box-shadow var(--motion-base) var(--ease-standard),
transform var(--motion-base) var(--ease-emphasized);
}
.chart-card--compact {
min-height: 150px;
}
.chart-card--compact .line-chart {
height: 78px;
}
.chart-card--compact .card-head {
margin-bottom: var(--space-2);
}
.chart-card--compact .segmented {
display: none;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.segmented {
display: inline-flex;
height: 30px;
padding: calc(var(--space-1) / 2);
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-card-strong);
}
.segment {
min-width: 52px;
padding: 0 var(--space-3);
border-radius: var(--radius-control);
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
font-size: var(--admin-font-size);
transition:
background var(--motion-fast) var(--ease-standard),
color var(--motion-fast) var(--ease-standard),
transform var(--motion-base) var(--ease-emphasized);
}
.segment:hover {
transform: translateY(-1px);
}
.segment--active {
background: var(--primary-surface);
color: var(--text-primary);
}
.line-chart {
width: 100%;
height: 216px;
opacity: 0;
animation: panel-enter 420ms var(--ease-emphasized) 120ms both;
}
.echart {
width: 100%;
min-width: 0;
}
.line-chart__grid {
stroke: var(--chart-grid);
stroke-width: 1;
}
.line-chart__area {
opacity: 0.18;
}
.line-chart__line {
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.donut-wrap {
display: grid;
grid-template-columns: 140px 1fr;
align-items: center;
gap: var(--space-6);
min-height: 216px;
}
.donut-echart {
width: 140px;
height: 140px;
opacity: 0;
animation: panel-enter 420ms var(--ease-emphasized) 120ms both;
}
.donut {
display: grid;
width: 140px;
height: 140px;
place-items: center;
border-radius: var(--radius-pill);
}
.donut-core {
display: grid;
width: 78px;
height: 78px;
place-items: center;
border-radius: var(--radius-pill);
background: var(--bg-card);
color: var(--text-secondary);
font-size: var(--admin-font-size);
}
.donut-core strong {
display: block;
color: var(--text-primary);
font-size: var(--admin-font-size);
line-height: 1;
}
.donut-legend {
display: grid;
gap: var(--space-3);
}
.legend-row {
display: grid;
grid-template-columns: 8px 1fr auto;
align-items: center;
gap: var(--space-3);
color: var(--text-secondary);
font-size: var(--admin-font-size);
}
.legend-row strong {
color: var(--text-primary);
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: var(--radius-pill);
}
.legend-dot--success {
background: var(--success);
}
.legend-dot--danger {
background: var(--danger);
}
.legend-dot--stopped {
background: var(--stopped);
}
.bar-chart {
display: block;
width: 100%;
height: 216px;
opacity: 0;
animation: panel-enter 420ms var(--ease-emphasized) 120ms both;
}
.alert-list {
display: grid;
gap: var(--space-3);
}
.alert-item {
display: grid;
grid-template-columns: 8px 1fr auto;
align-items: center;
gap: var(--space-3);
min-height: 42px;
animation: row-enter var(--motion-slow) var(--ease-emphasized) both;
transition:
background var(--motion-base) var(--ease-standard),
transform var(--motion-base) var(--ease-emphasized);
}
.alert-item:nth-child(2) {
animation-delay: 50ms;
}
.alert-item:nth-child(3) {
animation-delay: 100ms;
}
.alert-item:hover {
transform: translateX(2px);
}
.alert-dot {
width: 8px;
height: 8px;
border-radius: var(--radius-pill);
}
.alert-dot--danger {
background: var(--danger);
}
.alert-dot--warning {
background: var(--warning);
}
.alert-dot--info {
background: var(--info);
}
.alert-name {
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 620;
}
.alert-desc,
.alert-time {
color: var(--text-tertiary);
font-size: var(--admin-font-size);
}
.table-card {
margin-top: var(--space-4);
overflow: hidden;
animation: panel-enter var(--motion-slow) var(--ease-emphasized) both;
transition:
border-color var(--motion-base) var(--ease-standard),
box-shadow var(--motion-base) var(--ease-standard),
transform var(--motion-base) var(--ease-emphasized);
}
.split-grid {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: var(--space-4);
margin-top: var(--space-4);
}
.server-rail {
overflow: hidden;
animation: panel-enter var(--motion-slow) var(--ease-emphasized) both;
transition:
border-color var(--motion-base) var(--ease-standard),
box-shadow var(--motion-base) var(--ease-standard),
transform var(--motion-base) var(--ease-emphasized);
}
.server-list {
display: grid;
gap: 0;
padding: var(--space-2);
}
.server-item {
display: grid;
grid-template-columns: 8px 1fr auto;
align-items: center;
gap: var(--space-3);
width: 100%;
min-height: 64px;
padding: var(--space-3);
border-radius: var(--radius-control);
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: left;
animation: row-enter var(--motion-slow) var(--ease-emphasized) both;
transition:
background var(--motion-base) var(--ease-standard),
transform var(--motion-base) var(--ease-emphasized);
}
.server-item:hover {
background: var(--bg-hover);
transform: translateX(3px);
}
.server-item:nth-child(2) {
animation-delay: 40ms;
}
.server-item:nth-child(3) {
animation-delay: 80ms;
}
.server-item:nth-child(4) {
animation-delay: 120ms;
}
.server-item strong,
.server-item small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.server-item small {
margin-top: var(--space-1);
color: var(--text-tertiary);
font-size: var(--admin-font-size);
}
.server-status {
width: 8px;
height: 8px;
border-radius: var(--radius-pill);
background: var(--stopped);
}
.server-status--running {
background: var(--success);
}
.server-status--warning {
background: var(--warning);
}
.server-status--error {
background: var(--danger);
}
.link-button {
background: transparent;
color: var(--primary);
cursor: pointer;
font-size: var(--admin-font-size);
}
.alert-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
margin-top: var(--space-4);
padding-bottom: var(--space-2);
}

View File

@ -0,0 +1,49 @@
import { useCallback, useMemo, useState } from "react";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { getDashboardOverview } from "@/features/dashboard/api";
const emptyData = { overview: null };
export function useDashboardPage() {
const [range, setRange] = useState("近 1 小时");
const queryFn = useCallback(async () => {
const overview = await getDashboardOverview();
return { overview };
}, []);
const { data, error, loading, reload } = useAdminQuery(queryFn, {
errorMessage: "加载总览失败",
initialData: emptyData,
keepPreviousData: true,
queryKey: ["dashboard", "overview"]
});
const overview = data?.overview;
const stats = useMemo(() => {
if (!overview) {
return { activeUsers: 0, disabledUsers: 0, lockedUsers: 0, logsToday: 0, menuTotal: 0, rolesTotal: 0, unreadNotifications: 0, usersTotal: 0 };
}
return {
activeUsers: overview.activeUsers,
disabledUsers: overview.disabledUsers,
lockedUsers: overview.lockedUsers,
logsToday: overview.logsToday,
menuTotal: overview.menuTotal,
rolesTotal: overview.rolesTotal,
unreadNotifications: overview.unreadNotifications,
usersTotal: overview.usersTotal
};
}, [overview]);
return {
error,
loading,
overview,
range,
reload,
setRange,
stats
};
}

View File

@ -0,0 +1,28 @@
import { DataState } from "@/shared/ui/DataState.jsx";
import { DashboardAlerts } from "@/features/dashboard/components/DashboardAlerts.jsx";
import { DashboardCharts } from "@/features/dashboard/components/DashboardCharts.jsx";
import { DashboardStats } from "@/features/dashboard/components/DashboardStats.jsx";
import { DashboardToolbar } from "@/features/dashboard/components/DashboardToolbar.jsx";
import { useDashboardPage } from "@/features/dashboard/hooks/useDashboardPage.js";
export function OverviewPage() {
const page = useDashboardPage();
return (
<>
<DashboardToolbar
onRefresh={page.reload}
onRangeChange={page.setRange}
overview={page.overview}
range={page.range}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<DashboardStats overview={page.overview} stats={page.stats} />
<DashboardCharts overview={page.overview} />
<DashboardAlerts alerts={page.overview?.alerts || []} />
</DataState>
</>
);
}

View File

@ -0,0 +1,3 @@
export function useDashboardAbilities() {
return {};
}

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const dashboardRoutes = [
{
label: "系统概览",
loader: () => import("./pages/OverviewPage.jsx").then((module) => module.OverviewPage),
menuCode: MENU_CODES.overview,
pageKey: "overview",
path: "/overview",
permission: PERMISSIONS.overviewView
}
];

View File

@ -0,0 +1,101 @@
import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request";
import {
createCountry,
createCoinSeller,
createRegion,
disableRegion,
listAgencies,
listBDs,
listCoinSellers,
listCountries,
listHosts,
listRegions,
replaceRegionCountries,
setCoinSellerStatus,
updateCountry
} from "./api";
afterEach(() => {
setAccessToken("");
vi.unstubAllGlobals();
});
test("host org list APIs use generated admin paths and filters", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 10, total: 0 } })))
);
await listBDs({ page: 1, page_size: 10, parent_leader_user_id: 21, region_id: 7, status: "active" });
await listAgencies({ keyword: "agency", page: 2, page_size: 10, parent_bd_user_id: 31 });
await listHosts({ agency_id: 41, page: 1, page_size: 10 });
await listCoinSellers({ page: 1, page_size: 10, region_id: 7, status: "active" });
await createCoinSeller({ commandId: "coin-seller-test", reason: "contact", targetUserId: 1001 });
await setCoinSellerStatus(1001, { commandId: "coin-seller-status-test", reason: "disable", status: "disabled" });
const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0];
const [agencyUrl] = vi.mocked(fetch).mock.calls[1];
const [hostUrl] = vi.mocked(fetch).mock.calls[2];
const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[3];
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[4];
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[5];
expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
expect(String(bdUrl)).toContain("parent_leader_user_id=21");
expect(String(bdUrl)).toContain("region_id=7");
expect(String(bdUrl)).toContain("status=active");
expect(bdInit?.method).toBe("GET");
expect(String(agencyUrl)).toContain("/api/v1/admin/agencies?");
expect(String(agencyUrl)).toContain("parent_bd_user_id=31");
expect(String(hostUrl)).toContain("/api/v1/admin/hosts?");
expect(String(hostUrl)).toContain("agency_id=41");
expect(String(coinSellerUrl)).toContain("/api/v1/admin/coin-sellers?");
expect(String(coinSellerUrl)).toContain("region_id=7");
expect(String(coinSellerUrl)).toContain("status=active");
expect(coinSellerInit?.method).toBe("GET");
expect(String(coinSellerCreateUrl)).toContain("/api/v1/admin/coin-sellers");
expect(coinSellerCreateInit?.method).toBe("POST");
expect(String(coinSellerStatusUrl)).toContain("/api/v1/admin/coin-sellers/1001/status");
expect(coinSellerStatusInit?.method).toBe("PATCH");
});
test("country and region APIs use generated admin paths", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [], total: 0 } })))
);
await listCountries({ enabled: true });
await createCountry({ countryCode: "US", countryDisplayName: "United States", countryName: "United States", enabled: true });
await updateCountry(12, { countryDisplayName: "United States", countryName: "United States" });
await listRegions({ status: "active" });
await createRegion({ countries: ["US"], name: "North America", regionCode: "NA" });
await replaceRegionCountries(7, { countries: ["US", "CA"] });
await disableRegion(7);
const [countryListUrl, countryListInit] = vi.mocked(fetch).mock.calls[0];
const [countryCreateUrl, countryCreateInit] = vi.mocked(fetch).mock.calls[1];
const [countryUpdateUrl, countryUpdateInit] = vi.mocked(fetch).mock.calls[2];
const [regionListUrl, regionListInit] = vi.mocked(fetch).mock.calls[3];
const [regionCreateUrl, regionCreateInit] = vi.mocked(fetch).mock.calls[4];
const [regionCountriesUrl, regionCountriesInit] = vi.mocked(fetch).mock.calls[5];
const [regionDisableUrl, regionDisableInit] = vi.mocked(fetch).mock.calls[6];
expect(String(countryListUrl)).toContain("/api/v1/admin/countries?");
expect(String(countryListUrl)).toContain("enabled=true");
expect(countryListInit?.method).toBe("GET");
expect(String(countryCreateUrl)).toContain("/api/v1/admin/countries");
expect(countryCreateInit?.method).toBe("POST");
expect(String(countryUpdateUrl)).toContain("/api/v1/admin/countries/12");
expect(countryUpdateInit?.method).toBe("PATCH");
expect(String(regionListUrl)).toContain("/api/v1/admin/regions?");
expect(String(regionListUrl)).toContain("status=active");
expect(regionListInit?.method).toBe("GET");
expect(String(regionCreateUrl)).toContain("/api/v1/admin/regions");
expect(regionCreateInit?.method).toBe("POST");
expect(String(regionCountriesUrl)).toContain("/api/v1/admin/regions/7/countries");
expect(regionCountriesInit?.method).toBe("PUT");
expect(String(regionDisableUrl)).toContain("/api/v1/admin/regions/7");
expect(regionDisableInit?.method).toBe("DELETE");
});

View File

@ -0,0 +1,247 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
export { listRegions } from "@/shared/api/regions";
import type {
AgencyDto,
AgencyJoinEnabledPayload,
ApiList,
ApiPage,
BDProfileDto,
BDStatusPayload,
CoinSellerDto,
CoinSellerStatusPayload,
CountryDto,
CountryPayload,
CountryUpdatePayload,
CreateAgencyPayload,
CreateAgencyResultDto,
CreateBDLeaderPayload,
CreateBDPayload,
CreateCoinSellerPayload,
EntityId,
HostCommandPayload,
HostProfileDto,
PageQuery,
RegionCountriesPayload,
RegionDto,
RegionPayload,
RegionUpdatePayload
} from "@/shared/api/types";
export function listCountries(query: PageQuery = {}): Promise<ApiList<CountryDto>> {
const endpoint = API_ENDPOINTS.listCountries;
return apiRequest<ApiList<CountryDto>>(apiEndpointPath(API_OPERATIONS.listCountries), {
method: endpoint.method,
query
});
}
export function getCountry(countryId: EntityId): Promise<CountryDto> {
const endpoint = API_ENDPOINTS.getCountry;
return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.getCountry, { country_id: countryId }), {
method: endpoint.method
});
}
export function createCountry(payload: CountryPayload): Promise<CountryDto> {
const endpoint = API_ENDPOINTS.createCountry;
return apiRequest<CountryDto, CountryPayload>(apiEndpointPath(API_OPERATIONS.createCountry), {
body: payload,
method: endpoint.method
});
}
export function updateCountry(countryId: EntityId, payload: CountryUpdatePayload): Promise<CountryDto> {
const endpoint = API_ENDPOINTS.updateCountry;
return apiRequest<CountryDto, CountryUpdatePayload>(apiEndpointPath(API_OPERATIONS.updateCountry, { country_id: countryId }), {
body: payload,
method: endpoint.method
});
}
export function enableCountry(countryId: EntityId): Promise<CountryDto> {
const endpoint = API_ENDPOINTS.enableCountry;
return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.enableCountry, { country_id: countryId }), {
method: endpoint.method
});
}
export function disableCountry(countryId: EntityId): Promise<CountryDto> {
const endpoint = API_ENDPOINTS.disableCountry;
return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.disableCountry, { country_id: countryId }), {
method: endpoint.method
});
}
export function deleteCountry(countryId: EntityId): Promise<CountryDto> {
const endpoint = API_ENDPOINTS.deleteCountry;
return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.deleteCountry, { country_id: countryId }), {
method: endpoint.method
});
}
export function getRegion(regionId: EntityId): Promise<RegionDto> {
const endpoint = API_ENDPOINTS.getRegion;
return apiRequest<RegionDto>(apiEndpointPath(API_OPERATIONS.getRegion, { region_id: regionId }), {
method: endpoint.method
});
}
export function createRegion(payload: RegionPayload): Promise<RegionDto> {
const endpoint = API_ENDPOINTS.createRegion;
return apiRequest<RegionDto, RegionPayload>(apiEndpointPath(API_OPERATIONS.createRegion), {
body: payload,
method: endpoint.method
});
}
export function updateRegion(regionId: EntityId, payload: RegionUpdatePayload): Promise<RegionDto> {
const endpoint = API_ENDPOINTS.updateRegion;
return apiRequest<RegionDto, RegionUpdatePayload>(apiEndpointPath(API_OPERATIONS.updateRegion, { region_id: regionId }), {
body: payload,
method: endpoint.method
});
}
export function replaceRegionCountries(regionId: EntityId, payload: RegionCountriesPayload): Promise<RegionDto> {
const endpoint = API_ENDPOINTS.replaceRegionCountries;
return apiRequest<RegionDto, RegionCountriesPayload>(
apiEndpointPath(API_OPERATIONS.replaceRegionCountries, { region_id: regionId }),
{
body: payload,
method: endpoint.method
}
);
}
export function enableRegion(regionId: EntityId): Promise<RegionDto> {
const endpoint = API_ENDPOINTS.enableRegion;
return apiRequest<RegionDto>(apiEndpointPath(API_OPERATIONS.enableRegion, { region_id: regionId }), {
method: endpoint.method
});
}
export function disableRegion(regionId: EntityId): Promise<RegionDto> {
const endpoint = API_ENDPOINTS.disableRegion;
return apiRequest<RegionDto>(apiEndpointPath(API_OPERATIONS.disableRegion, { region_id: regionId }), {
method: endpoint.method
});
}
export function listBDLeaders(query: PageQuery = {}): Promise<ApiPage<BDProfileDto>> {
const endpoint = API_ENDPOINTS.listBDLeaders;
return apiRequest<ApiPage<BDProfileDto>>(apiEndpointPath(API_OPERATIONS.listBDLeaders), {
method: endpoint.method,
query
});
}
export function listBDs(query: PageQuery = {}): Promise<ApiPage<BDProfileDto>> {
const endpoint = API_ENDPOINTS.listBDs;
return apiRequest<ApiPage<BDProfileDto>>(apiEndpointPath(API_OPERATIONS.listBDs), {
method: endpoint.method,
query
});
}
export function listAgencies(query: PageQuery = {}): Promise<ApiPage<AgencyDto>> {
const endpoint = API_ENDPOINTS.listAgencies;
return apiRequest<ApiPage<AgencyDto>>(apiEndpointPath(API_OPERATIONS.listAgencies), {
method: endpoint.method,
query
});
}
export function listHosts(query: PageQuery = {}): Promise<ApiPage<HostProfileDto>> {
const endpoint = API_ENDPOINTS.listHosts;
return apiRequest<ApiPage<HostProfileDto>>(apiEndpointPath(API_OPERATIONS.listHosts), {
method: endpoint.method,
query
});
}
export function listCoinSellers(query: PageQuery = {}): Promise<ApiPage<CoinSellerDto>> {
const endpoint = API_ENDPOINTS.listCoinSellers;
return apiRequest<ApiPage<CoinSellerDto>>(apiEndpointPath(API_OPERATIONS.listCoinSellers), {
method: endpoint.method,
query
});
}
export function createBDLeader(payload: CreateBDLeaderPayload): Promise<BDProfileDto> {
const endpoint = API_ENDPOINTS.createBDLeader;
return apiRequest<BDProfileDto, CreateBDLeaderPayload>(apiEndpointPath(API_OPERATIONS.createBDLeader), {
body: payload,
method: endpoint.method
});
}
export function createBD(payload: CreateBDPayload): Promise<BDProfileDto> {
const endpoint = API_ENDPOINTS.createBD;
return apiRequest<BDProfileDto, CreateBDPayload>(apiEndpointPath(API_OPERATIONS.createBD), {
body: payload,
method: endpoint.method
});
}
export function setBDLeaderStatus(userId: EntityId, payload: BDStatusPayload): Promise<BDProfileDto> {
const endpoint = API_ENDPOINTS.setBDLeaderStatus;
return apiRequest<BDProfileDto, BDStatusPayload>(apiEndpointPath(API_OPERATIONS.setBDLeaderStatus, { user_id: userId }), {
body: payload,
method: endpoint.method
});
}
export function setBDStatus(userId: EntityId, payload: BDStatusPayload): Promise<BDProfileDto> {
const endpoint = API_ENDPOINTS.setBDStatus;
return apiRequest<BDProfileDto, BDStatusPayload>(apiEndpointPath(API_OPERATIONS.setBDStatus, { user_id: userId }), {
body: payload,
method: endpoint.method
});
}
export function createCoinSeller(payload: CreateCoinSellerPayload): Promise<CoinSellerDto> {
const endpoint = API_ENDPOINTS.createCoinSeller;
return apiRequest<CoinSellerDto, CreateCoinSellerPayload>(apiEndpointPath(API_OPERATIONS.createCoinSeller), {
body: payload,
method: endpoint.method
});
}
export function setCoinSellerStatus(userId: EntityId, payload: CoinSellerStatusPayload): Promise<CoinSellerDto> {
const endpoint = API_ENDPOINTS.setCoinSellerStatus;
return apiRequest<CoinSellerDto, CoinSellerStatusPayload>(
apiEndpointPath(API_OPERATIONS.setCoinSellerStatus, { user_id: userId }),
{
body: payload,
method: endpoint.method
}
);
}
export function createAgency(payload: CreateAgencyPayload): Promise<CreateAgencyResultDto> {
const endpoint = API_ENDPOINTS.createAgency;
return apiRequest<CreateAgencyResultDto, CreateAgencyPayload>(apiEndpointPath(API_OPERATIONS.createAgency), {
body: payload,
method: endpoint.method
});
}
export function closeAgency(agencyId: EntityId, payload: HostCommandPayload): Promise<AgencyDto> {
const endpoint = API_ENDPOINTS.closeAgency;
return apiRequest<AgencyDto, HostCommandPayload>(apiEndpointPath(API_OPERATIONS.closeAgency, { agency_id: agencyId }), {
body: payload,
method: endpoint.method
});
}
export function setAgencyJoinEnabled(agencyId: EntityId, payload: AgencyJoinEnabledPayload): Promise<AgencyDto> {
const endpoint = API_ENDPOINTS.setAgencyJoinEnabled;
return apiRequest<AgencyDto, AgencyJoinEnabledPayload>(
apiEndpointPath(API_OPERATIONS.setAgencyJoinEnabled, { agency_id: agencyId }),
{
body: payload,
method: endpoint.method
}
);
}

View File

@ -0,0 +1,29 @@
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
export function HostOrgActionModal({
children,
disabled,
loading,
onClose,
onSubmit,
open,
submitLabel = "提交",
submitVariant = "primary",
title
}) {
return (
<AdminFormDialog
loading={loading}
open={open}
size="compact"
submitDisabled={disabled || loading}
submitLabel={submitLabel}
submitVariant={submitVariant}
title={title}
onClose={onClose}
onSubmit={onSubmit}
>
{children}
</AdminFormDialog>
);
}

View File

@ -0,0 +1,50 @@
import TextField from "@mui/material/TextField";
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
import styles from "@/features/host-org/host-org.module.css";
export function HostOrgFilters({
extraFilters = [],
loadingRegions = false,
onQueryChange,
onRegionIdChange,
onStatusChange,
query,
regionId,
regionOptions = [],
searchPlaceholder,
status,
statusOptions
}) {
return (
<div className={styles.filters}>
<SearchBox className={styles.search} value={query} onChange={onQueryChange} placeholder={searchPlaceholder} />
<StatusSelect
className={styles.statusSelect}
options={statusOptions}
value={status}
onChange={onStatusChange}
/>
<div className={styles.filterFields}>
<RegionSelect
emptyLabel="全部区域"
loading={loadingRegions}
options={regionOptions}
value={regionId}
onChange={onRegionIdChange}
/>
{extraFilters.map((filter) => (
<TextField
key={filter.name}
label={filter.label}
size="small"
type="number"
value={filter.value}
onChange={(event) => filter.onChange(event.target.value)}
/>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,23 @@
import { statusLabels } from "@/features/host-org/constants.js";
export function HostOrgStatus({ value }) {
const status = value || "-";
const tone = getStatusTone(status);
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{statusLabels[status] || status}
</span>
);
}
function getStatusTone(status) {
if (status === "active") {
return "running";
}
if (status === "closed" || status === "disabled") {
return "danger";
}
return "stopped";
}

View File

@ -0,0 +1,5 @@
import { DataTable } from "@/shared/ui/DataTable.jsx";
export function HostOrgTable(props) {
return <DataTable {...props} />;
}

View File

@ -0,0 +1,46 @@
import Tooltip from "@mui/material/Tooltip";
import { HostOrgFilters } from "@/features/host-org/components/HostOrgFilters.jsx";
import styles from "@/features/host-org/host-org.module.css";
import { IconButton } from "@/shared/ui/IconButton.jsx";
export function HostOrgToolbar({ actions = [], filters }) {
return (
<div className={styles.toolbar}>
<div className={styles.toolbarLeft}>
<HostOrgFilters {...filters} />
</div>
{actions.length ? (
<div className={styles.toolbarActions}>
{actions.map((action) => (
<Tooltip arrow key={action.label} title={action.label}>
<span>
<IconButton
className={styles.toolbarAction}
disabled={action.disabled}
label={action.label}
tone={action.tone}
onClick={action.onClick}
sx={action.variant === "primary" ? primaryActionSx : undefined}
>
{action.icon}
</IconButton>
</span>
</Tooltip>
))}
</div>
) : null}
</div>
);
}
const primaryActionSx = {
borderColor: "var(--primary)",
backgroundColor: "var(--primary)",
color: "var(--active-contrast)",
"&:hover": {
borderColor: "var(--primary-strong)",
backgroundColor: "var(--primary-strong)",
color: "var(--active-contrast)"
}
};

View File

@ -0,0 +1,47 @@
export const agencyStatusFilters = [
["", "全部"],
["active", "启用"],
["closed", "已关闭"]
];
export const countryEnabledFilters = [
["", "全部"],
["enabled", "启用"],
["disabled", "停用"]
];
export const regionStatusFilters = [
["", "全部"],
["active", "启用"],
["disabled", "停用"]
];
export const bdStatusFilters = [
["", "全部"],
["active", "启用"],
["disabled", "停用"]
];
export const hostStatusFilters = [
["", "全部"],
["active", "启用"],
["disabled", "停用"]
];
export const coinSellerStatusFilters = [
["", "全部"],
["active", "启用"],
["disabled", "停用"]
];
export const hostSourceLabels = {
agency_application: "Agency 申请",
agency_invitation: "Agency 邀请",
admin_create_agency: "后台创建 Agency"
};
export const statusLabels = {
active: "启用",
closed: "已关闭",
disabled: "停用"
};

View File

@ -0,0 +1,28 @@
import { useCallback, useMemo } from "react";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { listCountries } from "@/features/host-org/api";
const emptyData = { items: [], total: 0 };
export function useCountryOptions() {
const queryFn = useCallback(() => listCountries({ enabled: true }), []);
const { data = emptyData, loading: loadingCountries } = useAdminQuery(queryFn, {
errorMessage: "加载国家选项失败",
initialData: emptyData,
queryKey: ["host-org", "countries", "enabled-options"],
staleTime: 5 * 60 * 1000
});
const countryOptions = useMemo(() => {
return (data?.items || []).map((country) => ({
label: formatCountryOptionLabel(country),
value: country.countryCode
}));
}, [data?.items]);
return { countryOptions, loadingCountries };
}
function formatCountryOptionLabel(country) {
return [country.flag, country.countryDisplayName || country.countryName, country.countryCode].filter(Boolean).join(" · ");
}

View File

@ -0,0 +1,178 @@
import { useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
closeAgency,
createAgency,
listAgencies,
setAgencyJoinEnabled
} from "@/features/host-org/api";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useAgencyAbilities } from "@/features/host-org/permissions.js";
import {
agencyCloseSchema,
agencyJoinEnabledSchema,
createAgencySchema
} from "@/features/host-org/schema";
const emptyAgencyForm = () => ({
commandId: makeCommandId("agency"),
joinEnabled: true,
maxHosts: 0,
name: "",
ownerUserId: "",
parentBdUserId: "",
reason: ""
});
const emptyCloseForm = () => ({ agencyId: "", commandId: makeCommandId("agency-close"), reason: "" });
const emptyJoinEnabledForm = () => ({
agencyId: "",
commandId: makeCommandId("agency-join"),
joinEnabled: true,
reason: ""
});
const pageSize = 10;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
export function useHostAgenciesPage() {
const abilities = useAgencyAbilities();
const { showToast } = useToast();
const { loadingRegions, regionOptions } = useRegionOptions();
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [regionId, setRegionId] = useState("");
const [parentBdUserId, setParentBdUserId] = useState("");
const [page, setPage] = useState(1);
const [agencyForm, setAgencyForm] = useState(emptyAgencyForm);
const [closeForm, setCloseForm] = useState(emptyCloseForm);
const [joinEnabledForm, setJoinEnabledForm] = useState(emptyJoinEnabledForm);
const [loadingAction, setLoadingAction] = useState("");
const [activeAction, setActiveAction] = useState("");
const filters = useMemo(
() => ({
keyword: query,
parent_bd_user_id: parentBdUserId,
region_id: regionId,
status
}),
[parentBdUserId, query, regionId, status]
);
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
errorMessage: "加载 Agency 列表失败",
fetcher: listAgencies,
filters,
page,
pageSize,
queryKey: ["host-org", "agencies", filters, page]
});
const changeQuery = (value) => {
setQuery(value);
setPage(1);
};
const changeStatus = (value) => {
setStatus(value);
setPage(1);
};
const changeRegionId = (value) => {
setRegionId(value);
setPage(1);
};
const changeParentBdUserId = (value) => {
setParentBdUserId(value);
setPage(1);
};
const submitAgency = async (event) => {
event.preventDefault();
await runAction("agency", "Agency 已创建", async () => {
const payload = parseForm(createAgencySchema, agencyForm);
const data = await createAgency(payload);
setAgencyForm(emptyAgencyForm());
setActiveAction("");
await reload();
return data;
});
};
const submitClose = async (event) => {
event.preventDefault();
await runAction("agency-close", "Agency 已关闭", async () => {
const payload = parseForm(agencyCloseSchema, closeForm);
const { agencyId, ...body } = payload;
const data = await closeAgency(agencyId, body);
setCloseForm(emptyCloseForm());
setActiveAction("");
await reload();
return data;
});
};
const submitJoinEnabled = async (event) => {
event.preventDefault();
await runAction("agency-join", "入会开关已更新", async () => {
const payload = parseForm(agencyJoinEnabledSchema, joinEnabledForm);
const { agencyId, ...body } = payload;
const data = await setAgencyJoinEnabled(agencyId, body);
setJoinEnabledForm(emptyJoinEnabledForm());
setActiveAction("");
await reload();
return data;
});
};
const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action);
try {
await submitter();
showToast(successMessage, "success");
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
agencyForm,
changeParentBdUserId,
changeQuery,
changeRegionId,
changeStatus,
closeForm,
data,
error,
joinEnabledForm,
loading,
loadingAction,
loadingRegions,
page,
parentBdUserId,
query,
regionId,
regionOptions,
reload,
closeAction: () => setActiveAction(""),
openAgencyForm: () => setActiveAction("agency"),
openCloseForm: () => setActiveAction("agency-close"),
openJoinEnabledForm: () => setActiveAction("agency-join"),
setAgencyForm,
setCloseForm,
setJoinEnabledForm,
setPage,
status,
submitAgency,
submitClose,
submitJoinEnabled
};
}
function makeCommandId(prefix) {
return `${prefix}-${Date.now()}`;
}

View File

@ -0,0 +1,166 @@
import { useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
createBD,
createBDLeader,
listBDLeaders,
listBDs,
setBDLeaderStatus,
setBDStatus
} from "@/features/host-org/api";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useBDAbilities } from "@/features/host-org/permissions.js";
import { bdStatusSchema, createBDSchema, createBDLeaderSchema } from "@/features/host-org/schema";
const emptyBDLeaderForm = () => ({ commandId: makeCommandId("bd-leader"), reason: "", regionId: "", targetUserId: "" });
const emptyBDForm = () => ({ commandId: makeCommandId("bd"), parentLeaderUserId: "", reason: "", targetUserId: "" });
const emptyBDStatusForm = (targetType = "bd") => ({ commandId: makeCommandId("bd-status"), reason: "", status: "active", targetType, targetUserId: "" });
const pageSize = 10;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
export function useHostBdsPage({ profileType = "bd" } = {}) {
const abilities = useBDAbilities();
const { showToast } = useToast();
const { loadingRegions, regionOptions } = useRegionOptions();
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [regionId, setRegionId] = useState("");
const [parentLeaderUserId, setParentLeaderUserId] = useState("");
const [page, setPage] = useState(1);
const [bdLeaderForm, setBDLeaderForm] = useState(emptyBDLeaderForm);
const [bdForm, setBDForm] = useState(emptyBDForm);
const [bdStatusForm, setBDStatusForm] = useState(() => emptyBDStatusForm(profileType));
const [loadingAction, setLoadingAction] = useState("");
const [activeAction, setActiveAction] = useState("");
const filters = useMemo(
() => ({
keyword: query,
parent_leader_user_id: profileType === "bd" ? parentLeaderUserId : "",
region_id: regionId,
status
}),
[parentLeaderUserId, profileType, query, regionId, status]
);
const fetcher = profileType === "bd" ? listBDs : listBDLeaders;
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
errorMessage: profileType === "leader" ? "加载 BD Leader 列表失败" : "加载 BD 列表失败",
fetcher,
filters,
page,
pageSize,
queryKey: ["host-org", "bds", profileType, filters, page]
});
const changeQuery = (value) => {
setQuery(value);
setPage(1);
};
const changeStatus = (value) => {
setStatus(value);
setPage(1);
};
const changeRegionId = (value) => {
setRegionId(value);
setPage(1);
};
const changeParentLeaderUserId = (value) => {
setParentLeaderUserId(value);
setPage(1);
};
const submitBDLeader = async (event) => {
event.preventDefault();
await runAction("bd-leader", "BD Leader 已创建", async () => {
const payload = parseForm(createBDLeaderSchema, bdLeaderForm);
const data = await createBDLeader(payload);
setBDLeaderForm(emptyBDLeaderForm());
setActiveAction("");
await reload();
return data;
});
};
const submitBD = async (event) => {
event.preventDefault();
await runAction("bd", "BD 已创建", async () => {
const payload = parseForm(createBDSchema, bdForm);
const data = await createBD(payload);
setBDForm(emptyBDForm());
setActiveAction("");
await reload();
return data;
});
};
const submitBDStatus = async (event) => {
event.preventDefault();
await runAction("bd-status", profileType === "leader" ? "BD Leader 状态已更新" : "BD 状态已更新", async () => {
const payload = parseForm(bdStatusSchema, { ...bdStatusForm, targetType: profileType });
const { targetType, targetUserId, ...body } = payload;
const data = targetType === "leader"
? await setBDLeaderStatus(targetUserId, body)
: await setBDStatus(targetUserId, body);
setBDStatusForm(emptyBDStatusForm(profileType));
setActiveAction("");
await reload();
return data;
});
};
const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action);
try {
await submitter();
showToast(successMessage, "success");
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
bdForm,
bdLeaderForm,
bdStatusForm,
changeParentLeaderUserId,
changeQuery,
changeRegionId,
changeStatus,
data,
error,
loading,
loadingAction,
loadingRegions,
page,
parentLeaderUserId,
profileType,
query,
regionId,
regionOptions,
reload,
closeAction: () => setActiveAction(""),
openBDForm: () => setActiveAction("bd"),
openBDLeaderForm: () => setActiveAction("bd-leader"),
openBDStatusForm: () => setActiveAction("bd-status"),
setBDForm,
setBDLeaderForm,
setBDStatusForm,
setPage,
status,
submitBD,
submitBDLeader,
submitBDStatus
};
}
function makeCommandId(prefix) {
return `${prefix}-${Date.now()}`;
}

View File

@ -0,0 +1,198 @@
import { useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { createCoinSeller, listCoinSellers, setCoinSellerStatus } from "@/features/host-org/api";
import { useCoinSellerAbilities } from "@/features/host-org/permissions.js";
import { coinSellerStatusSchema, createCoinSellerSchema } from "@/features/host-org/schema";
const pageSize = 10;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyCreateForm = () => ({ commandId: makeCommandId("coin-seller"), reason: "", targetUserId: "" });
const emptyEditForm = () => ({ commandId: makeCommandId("coin-seller-status"), reason: "", status: "active", targetUserId: "" });
export function useHostCoinSellersPage() {
const abilities = useCoinSellerAbilities();
const confirm = useConfirm();
const { showToast } = useToast();
const { loadingRegions, regionOptions } = useRegionOptions();
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [regionId, setRegionId] = useState("");
const [page, setPage] = useState(1);
const [activeAction, setActiveAction] = useState("");
const [createForm, setCreateForm] = useState(emptyCreateForm);
const [editForm, setEditForm] = useState(emptyEditForm);
const [selectedSeller, setSelectedSeller] = useState(null);
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(
() => ({
keyword: query,
region_id: regionId,
status
}),
[query, regionId, status]
);
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
errorMessage: "加载币商列表失败",
fetcher: listCoinSellers,
filters,
page,
pageSize,
queryKey: ["host-org", "coin-sellers", filters, page]
});
const changeQuery = (value) => {
setQuery(value);
setPage(1);
};
const changeStatus = (value) => {
setStatus(value);
setPage(1);
};
const changeRegionId = (value) => {
setRegionId(value);
setPage(1);
};
const openCreateSeller = () => {
setCreateForm(emptyCreateForm());
setSelectedSeller(null);
setActiveAction("create");
};
const openEditSeller = (seller) => {
if (!seller?.userId) {
return;
}
setSelectedSeller(seller);
setEditForm({
commandId: makeCommandId("coin-seller-status"),
reason: "",
status: seller.status || "active",
targetUserId: seller.userId
});
setActiveAction("edit");
};
const closeAction = () => {
setActiveAction("");
setSelectedSeller(null);
};
const submitCreateSeller = async (event) => {
event.preventDefault();
await runAction("coin-seller-create", "币商已添加", async () => {
const payload = parseForm(createCoinSellerSchema, createForm);
await createCoinSeller(payload);
setCreateForm(emptyCreateForm());
closeAction();
await reload();
});
};
const submitEditSeller = async (event) => {
event.preventDefault();
await runAction(`coin-seller-edit-${editForm.targetUserId || selectedSeller?.userId || ""}`, "币商已更新", async () => {
const payload = parseForm(coinSellerStatusSchema, editForm);
const { targetUserId, ...body } = payload;
await setCoinSellerStatus(targetUserId, body);
setEditForm(emptyEditForm());
closeAction();
await reload();
});
};
const toggleSeller = async (seller, nextEnabled = seller.status !== "active") => {
if (!abilities.canUpdate || !seller?.userId) {
return;
}
const nextStatus = nextEnabled ? "active" : "disabled";
if (seller.status === nextStatus) {
return;
}
await runAction(`coin-seller-status-${seller.userId}`, nextEnabled ? "币商已启用" : "币商已停用", async () => {
await setCoinSellerStatus(seller.userId, {
commandId: makeCommandId("coin-seller-status"),
reason: "",
status: nextStatus
});
await reload();
});
};
const deleteSeller = async (seller) => {
if (!abilities.canUpdate || !seller?.userId) {
return;
}
const ok = await confirm({
confirmText: "删除",
message: seller.username || seller.displayUserId || String(seller.userId),
title: "删除币商",
tone: "danger"
});
if (!ok) {
return;
}
await runAction(`coin-seller-delete-${seller.userId}`, "币商已删除", async () => {
await setCoinSellerStatus(seller.userId, {
commandId: makeCommandId("coin-seller-delete"),
reason: "delete coin seller",
status: "disabled"
});
await reload();
});
};
const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action);
try {
await submitter();
showToast(successMessage, "success");
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
changeQuery,
changeRegionId,
changeStatus,
closeAction,
createForm,
data,
deleteSeller,
editForm,
error,
loading,
loadingAction,
loadingRegions,
openCreateSeller,
openEditSeller,
page,
query,
regionId,
regionOptions,
reload,
selectedSeller,
setCreateForm,
setEditForm,
setPage,
status,
submitCreateSeller,
submitEditSeller,
toggleSeller
};
}
function makeCommandId(prefix) {
return `${prefix}-${Date.now()}`;
}

View File

@ -0,0 +1,161 @@
import { useCallback, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
createCountry,
disableCountry,
enableCountry,
listCountries,
updateCountry
} from "@/features/host-org/api";
import { useCountryAbilities } from "@/features/host-org/permissions.js";
import { countryCreateSchema, countryUpdateSchema } from "@/features/host-org/schema";
const emptyCountryForm = () => ({
countryCode: "",
countryDisplayName: "",
countryName: "",
enabled: true,
flag: "",
isoAlpha3: "",
isoNumeric: "",
phoneCountryCode: "",
sortOrder: 0
});
const emptyData = { items: [], total: 0 };
export function useHostCountriesPage() {
const abilities = useCountryAbilities();
const { showToast } = useToast();
const [query, setQuery] = useState("");
const [enabledStatus, setEnabledStatus] = useState("");
const [countryForm, setCountryForm] = useState(emptyCountryForm);
const [editingCountry, setEditingCountry] = useState(null);
const [loadingAction, setLoadingAction] = useState("");
const [activeAction, setActiveAction] = useState("");
const filters = useMemo(
() => ({
enabled: enabledStatus === "enabled" ? true : enabledStatus === "disabled" ? false : undefined
}),
[enabledStatus]
);
const queryFn = useCallback(() => listCountries(filters), [filters]);
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
errorMessage: "加载国家列表失败",
initialData: emptyData,
keepPreviousData: true,
queryKey: ["host-org", "countries", filters]
});
const items = useMemo(() => filterCountries(data?.items || [], query), [data?.items, query]);
const changeQuery = (value) => {
setQuery(value);
};
const changeEnabledStatus = (value) => {
setEnabledStatus(value);
};
const openCreateCountry = () => {
setEditingCountry(null);
setCountryForm(emptyCountryForm());
setActiveAction("create");
};
const openEditCountry = (country) => {
setEditingCountry(country);
setCountryForm({
countryCode: country.countryCode || "",
countryDisplayName: country.countryDisplayName || "",
countryName: country.countryName || "",
enabled: Boolean(country.enabled),
flag: country.flag || "",
isoAlpha3: country.isoAlpha3 || "",
isoNumeric: country.isoNumeric || "",
phoneCountryCode: country.phoneCountryCode || "",
sortOrder: country.sortOrder || 0
});
setActiveAction("edit");
};
const submitCountry = async (event) => {
event.preventDefault();
const isEdit = activeAction === "edit";
await runAction(isEdit ? "country-edit" : "country-create", isEdit ? "国家已更新" : "国家已创建", async () => {
const payload = parseForm(isEdit ? countryUpdateSchema : countryCreateSchema, countryForm);
const data = isEdit
? await updateCountry(editingCountry.countryId, payload)
: await createCountry(payload);
setCountryForm(emptyCountryForm());
setEditingCountry(null);
setActiveAction("");
await reload();
return data;
});
};
const toggleCountry = async (country, nextEnabled = !country.enabled) => {
if (!abilities.canStatus || Boolean(country.enabled) === nextEnabled) {
return;
}
await runAction(`country-status-${country.countryId}`, nextEnabled ? "国家已启用" : "国家已禁用", async () => {
const data = nextEnabled ? await enableCountry(country.countryId) : await disableCountry(country.countryId);
await reload();
return data;
});
};
const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action);
try {
await submitter();
showToast(successMessage, "success");
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
changeEnabledStatus,
changeQuery,
closeAction: () => setActiveAction(""),
countryForm,
data: { ...data, items, total: items.length },
editingCountry,
enabledStatus,
error,
loading,
loadingAction,
openCreateCountry,
openEditCountry,
query,
reload,
setCountryForm,
submitCountry,
toggleCountry
};
}
function filterCountries(items, query) {
const keyword = query.trim().toLowerCase();
if (!keyword) {
return items;
}
return items.filter((item) => {
return [
item.countryCode,
item.countryDisplayName,
item.countryName,
item.isoAlpha3,
item.isoNumeric,
item.phoneCountryCode
]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword));
});
}

View File

@ -0,0 +1,75 @@
import { useMemo, useState } from "react";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { listHosts } from "@/features/host-org/api";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useHostAbilities } from "@/features/host-org/permissions.js";
const pageSize = 10;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
export function useHostHostsPage() {
const abilities = useHostAbilities();
const { loadingRegions, regionOptions } = useRegionOptions();
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [regionId, setRegionId] = useState("");
const [agencyId, setAgencyId] = useState("");
const [page, setPage] = useState(1);
const filters = useMemo(
() => ({
agency_id: agencyId,
keyword: query,
region_id: regionId,
status
}),
[agencyId, query, regionId, status]
);
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
errorMessage: "加载主播列表失败",
fetcher: listHosts,
filters,
page,
pageSize,
queryKey: ["host-org", "hosts", filters, page]
});
const changeQuery = (value) => {
setQuery(value);
setPage(1);
};
const changeStatus = (value) => {
setStatus(value);
setPage(1);
};
const changeRegionId = (value) => {
setRegionId(value);
setPage(1);
};
const changeAgencyId = (value) => {
setAgencyId(value);
setPage(1);
};
return {
abilities,
agencyId,
changeAgencyId,
changeQuery,
changeRegionId,
changeStatus,
data,
error,
loading,
loadingRegions,
page,
query,
regionId,
regionOptions,
reload,
setPage,
status
};
}

View File

@ -0,0 +1,175 @@
import { useCallback, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
createRegion,
disableRegion,
enableRegion,
listCountries,
listRegions,
replaceRegionCountries,
updateRegion
} from "@/features/host-org/api";
import { useRegionAbilities } from "@/features/host-org/permissions.js";
import { regionCreateSchema, regionUpdateSchema } from "@/features/host-org/schema";
const emptyRegionForm = () => ({
countries: [],
name: "",
regionCode: "",
sortOrder: 0
});
const emptyData = { items: [], total: 0 };
export function useHostRegionsPage() {
const abilities = useRegionAbilities();
const { showToast } = useToast();
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [regionForm, setRegionForm] = useState(emptyRegionForm);
const [editingRegion, setEditingRegion] = useState(null);
const [loadingAction, setLoadingAction] = useState("");
const [activeAction, setActiveAction] = useState("");
const filters = useMemo(() => ({ status }), [status]);
const queryFn = useCallback(() => listRegions(filters), [filters]);
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
errorMessage: "加载区域列表失败",
initialData: emptyData,
keepPreviousData: true,
queryKey: ["host-org", "regions", filters]
});
const countryQueryFn = useCallback(() => listCountries(), []);
const { data: countriesData = emptyData, loading: loadingCountries } = useAdminQuery(countryQueryFn, {
errorMessage: "加载国家列表失败",
initialData: emptyData,
queryKey: ["host-org", "countries", "region-options"],
staleTime: 5 * 60 * 1000
});
const items = useMemo(() => filterRegions(data?.items || [], query), [data?.items, query]);
const countryOptions = useMemo(() => {
return [...new Set((countriesData?.items || []).map((country) => country.countryCode).filter(Boolean))].sort();
}, [countriesData?.items]);
const countryLabels = useMemo(() => {
return (countriesData?.items || []).reduce((labels, country) => {
if (country.countryCode) {
labels[country.countryCode] = [country.countryCode, country.countryDisplayName || country.countryName].filter(Boolean).join(" · ");
}
return labels;
}, {});
}, [countriesData?.items]);
const changeQuery = (value) => {
setQuery(value);
};
const changeStatus = (value) => {
setStatus(value);
};
const openCreateRegion = () => {
setEditingRegion(null);
setRegionForm(emptyRegionForm());
setActiveAction("create");
};
const openEditRegion = (region) => {
setEditingRegion(region);
setRegionForm({
countries: region.countries || [],
name: region.name || "",
regionCode: region.regionCode || "",
sortOrder: region.sortOrder || 0
});
setActiveAction("edit");
};
const submitRegion = async (event) => {
event.preventDefault();
const isEdit = activeAction === "edit";
await runAction(isEdit ? "region-edit" : "region-create", isEdit ? "区域已更新" : "区域已创建", async () => {
const payload = parseForm(isEdit ? regionUpdateSchema : regionCreateSchema, regionForm);
const data = isEdit ? await updateExistingRegion(editingRegion, payload) : await createRegion(payload);
setRegionForm(emptyRegionForm());
setEditingRegion(null);
setActiveAction("");
await reload();
return data;
});
};
const toggleRegion = async (region, nextActive = region.status !== "active") => {
if (!abilities.canStatus || (region.status === "active") === nextActive) {
return;
}
await runAction(`region-status-${region.regionId}`, nextActive ? "区域已启用" : "区域已停用", async () => {
const data = nextActive ? await enableRegion(region.regionId) : await disableRegion(region.regionId);
await reload();
return data;
});
};
const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action);
try {
await submitter();
showToast(successMessage, "success");
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
changeQuery,
changeStatus,
closeAction: () => setActiveAction(""),
countryLabels,
countryOptions,
data: { ...data, items, total: items.length },
editingRegion,
error,
loading,
loadingAction,
loadingCountries,
openCreateRegion,
openEditRegion,
query,
regionForm,
reload,
setRegionForm,
status,
submitRegion,
toggleRegion
};
}
async function updateExistingRegion(region, payload) {
const { countries, ...regionPayload } = payload;
const updatedRegion = await updateRegion(region.regionId, regionPayload);
if (hasCountryChanges(region.countries || [], countries || [])) {
return replaceRegionCountries(region.regionId, { countries });
}
return updatedRegion;
}
function hasCountryChanges(previousCountries, nextCountries) {
const previous = [...previousCountries].sort();
const next = [...nextCountries].sort();
return previous.length !== next.length || previous.some((country, index) => country !== next[index]);
}
function filterRegions(items, query) {
const keyword = query.trim().toLowerCase();
if (!keyword) {
return items;
}
return items.filter((item) => {
return [item.regionCode, item.name, ...(item.countries || [])]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword));
});
}

View File

@ -0,0 +1,223 @@
.root {
display: flex;
height: calc(100vh - var(--header-height) - var(--space-8) - var(--space-4));
height: calc(100dvh - var(--header-height) - var(--space-8) - var(--space-4));
min-height: 0;
flex-direction: column;
}
.contentPanel {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card);
overflow: hidden;
}
.contentPanel :global(.data-state) {
flex: 1;
min-height: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.contentPanel :global(.table-frame) {
min-height: 0;
border: 0;
border-radius: 0;
}
.contentPanel :global(.table-scroll) {
overflow: auto;
}
.contentPanel :global(.admin-row--head) {
position: sticky;
top: 0;
z-index: 1;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.toolbarLeft {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: var(--space-3);
}
.toolbarActions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-2);
}
.filters {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.search {
flex: 0 1 300px;
}
.statusSelect {
flex: 0 0 150px;
}
.filterFields {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.filterFields :global(.MuiTextField-root) {
width: 170px;
}
.rowActions {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.countryList {
display: flex;
min-width: 0;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.countryBadge {
display: inline-flex;
height: var(--status-height);
align-items: center;
border: 1px solid var(--border);
border-radius: var(--radius-pill);
background: var(--bg-input);
color: var(--text-secondary);
font-size: var(--admin-font-size);
font-weight: 650;
padding: 0 var(--space-2);
}
.flagCell {
font-size: var(--admin-font-size);
}
.listBlock {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
background: var(--bg-card);
}
.listBlock :global(.pagination-bar) {
margin: var(--space-3) var(--space-5) var(--space-4);
}
.inlineValue {
color: var(--text-primary);
font-weight: 650;
}
.sellerIdentity {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.sellerAvatar {
display: inline-flex;
width: 32px;
height: 32px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--bg-input);
color: var(--text-secondary);
font-weight: 700;
object-fit: cover;
}
.sellerText {
display: inline-flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.sellerName {
overflow: hidden;
color: var(--text-primary);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.sellerMeta {
overflow: hidden;
color: var(--text-secondary);
font-size: var(--admin-font-size);
text-overflow: ellipsis;
white-space: nowrap;
}
.positive {
color: var(--success);
}
.negative {
color: var(--danger);
}
@media (max-width: 760px) {
.toolbar,
.toolbarLeft,
.filters,
.filterFields {
align-items: stretch;
flex-direction: column;
}
.toolbarActions {
align-self: flex-end;
}
.search {
flex: 1 1 auto;
width: 100%;
}
.statusSelect {
flex: 1 1 auto;
width: 100%;
}
.filterFields :global(.MuiTextField-root) {
width: 100%;
}
}

View File

@ -0,0 +1,142 @@
import Add from "@mui/icons-material/Add";
import BlockOutlined from "@mui/icons-material/BlockOutlined";
import ToggleOnOutlined from "@mui/icons-material/ToggleOnOutlined";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { agencyStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
import { useHostAgenciesPage } from "@/features/host-org/hooks/useHostAgenciesPage.js";
import styles from "@/features/host-org/host-org.module.css";
const agencyColumns = [
{ key: "agencyId", label: "Agency ID", width: "minmax(110px, 0.8fr)" },
{ key: "name", label: "名称", width: "minmax(160px, 1.2fr)" },
{ key: "ownerUserId", label: "Owner 用户 ID" },
{ key: "parentBdUserId", label: "上级 BD 用户 ID" },
{ key: "regionId", label: "区域 ID", width: "minmax(90px, 0.7fr)" },
{ key: "status", label: "状态", render: (item) => <HostOrgStatus value={item.status} />, width: "minmax(90px, 0.7fr)" },
{
key: "joinEnabled",
label: "入会",
render: (item) => <span className={item.joinEnabled ? styles.positive : styles.negative}>{item.joinEnabled ? "允许" : "禁止"}</span>,
width: "minmax(80px, 0.6fr)"
},
{ key: "maxHosts", label: "最大主播数", width: "minmax(90px, 0.7fr)" },
{ key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" }
];
export function HostAgenciesPage() {
const page = useHostAgenciesPage();
const createDisabled = !page.abilities.canCreate;
const statusDisabled = !page.abilities.canStatus;
const items = page.data.items || [];
const total = page.data.total || 0;
const toolbarActions = [
page.abilities.canStatus
? { icon: <ToggleOnOutlined fontSize="small" />, label: "入会开关", onClick: page.openJoinEnabledForm }
: null,
page.abilities.canStatus
? { icon: <BlockOutlined fontSize="small" />, label: "关闭 Agency", onClick: page.openCloseForm, tone: "danger" }
: null,
page.abilities.canCreate
? { icon: <Add fontSize="small" />, label: "创建 Agency", onClick: page.openAgencyForm, variant: "primary" }
: null
].filter(Boolean);
return (
<section className={styles.root}>
<div className={styles.contentPanel}>
<HostOrgToolbar
actions={toolbarActions}
filters={{
extraFilters: [
{
label: "上级 BD 用户 ID",
name: "parentBdUserId",
onChange: page.changeParentBdUserId,
value: page.parentBdUserId
}
],
loadingRegions: page.loadingRegions,
query: page.query,
regionId: page.regionId,
regionOptions: page.regionOptions,
searchPlaceholder: "搜索 Agency、Owner、用户...",
status: page.status,
statusOptions: agencyStatusFilters,
onQueryChange: page.changeQuery,
onRegionIdChange: page.changeRegionId,
onStatusChange: page.changeStatus
}}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}>
<HostOrgTable
columns={agencyColumns}
items={items}
rowKey={(item) => item.agencyId}
/>
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
</div>
</DataState>
</div>
<HostOrgActionModal
disabled={createDisabled}
loading={page.loadingAction === "agency"}
open={page.activeAction === "agency"}
onClose={page.closeAction}
onSubmit={page.submitAgency}
title="创建 Agency"
>
<TextField disabled={createDisabled} label="Agency 名称" required value={page.agencyForm.name} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, name: event.target.value })} />
<TextField disabled={createDisabled} label="Owner 短 ID" required type="number" value={page.agencyForm.ownerUserId} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, ownerUserId: event.target.value })} />
<TextField disabled={createDisabled} label="上级 BD 短 ID" required type="number" value={page.agencyForm.parentBdUserId} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, parentBdUserId: event.target.value })} />
<TextField disabled={createDisabled} label="最大主播数" type="number" value={page.agencyForm.maxHosts} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, maxHosts: event.target.value })} />
<FormControlLabel
control={<Switch checked={page.agencyForm.joinEnabled} disabled={createDisabled} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })} />}
label={page.agencyForm.joinEnabled ? "允许入会" : "禁止入会"}
/>
<TextField disabled={createDisabled} label="原因" multiline minRows={2} value={page.agencyForm.reason} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, reason: event.target.value })} />
</HostOrgActionModal>
<HostOrgActionModal
disabled={statusDisabled}
loading={page.loadingAction === "agency-join"}
open={page.activeAction === "agency-join"}
onClose={page.closeAction}
onSubmit={page.submitJoinEnabled}
title="入会开关"
>
<TextField disabled={statusDisabled} label="Agency ID" required type="number" value={page.joinEnabledForm.agencyId} onChange={(event) => page.setJoinEnabledForm({ ...page.joinEnabledForm, agencyId: event.target.value })} />
<FormControlLabel
control={<Switch checked={page.joinEnabledForm.joinEnabled} disabled={statusDisabled} onChange={(event) => page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked })} />}
label={page.joinEnabledForm.joinEnabled ? "允许入会" : "禁止入会"}
/>
<TextField disabled={statusDisabled} label="原因" multiline minRows={2} value={page.joinEnabledForm.reason} onChange={(event) => page.setJoinEnabledForm({ ...page.joinEnabledForm, reason: event.target.value })} />
</HostOrgActionModal>
<HostOrgActionModal
disabled={statusDisabled}
loading={page.loadingAction === "agency-close"}
open={page.activeAction === "agency-close"}
onClose={page.closeAction}
onSubmit={page.submitClose}
submitLabel="确认关闭"
submitVariant="danger"
title="关闭 Agency"
>
<TextField disabled={statusDisabled} label="Agency ID" required type="number" value={page.closeForm.agencyId} onChange={(event) => page.setCloseForm({ ...page.closeForm, agencyId: event.target.value })} />
<TextField disabled={statusDisabled} label="原因" multiline minRows={2} required value={page.closeForm.reason} onChange={(event) => page.setCloseForm({ ...page.closeForm, reason: event.target.value })} />
</HostOrgActionModal>
</section>
);
}

View File

@ -0,0 +1,103 @@
import Add from "@mui/icons-material/Add";
import SyncAltOutlined from "@mui/icons-material/SyncAltOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { bdStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
import styles from "@/features/host-org/host-org.module.css";
const bdLeaderColumns = [
{ key: "userId", label: "用户 ID", width: "minmax(110px, 0.8fr)" },
{ key: "role", label: "角色", width: "minmax(110px, 0.8fr)" },
{ key: "regionId", label: "区域 ID", width: "minmax(90px, 0.7fr)" },
{ key: "status", label: "状态", render: (item) => <HostOrgStatus value={item.status} />, width: "minmax(90px, 0.7fr)" },
{ key: "createdByUserId", label: "创建人用户 ID" },
{ key: "createdAtMs", label: "创建时间", render: (item) => formatMillis(item.createdAtMs), width: "minmax(170px, 1fr)" },
{ key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" }
];
export function HostBdLeadersPage() {
const page = useHostBdsPage({ profileType: "leader" });
const createDisabled = !page.abilities.canCreate;
const updateDisabled = !page.abilities.canUpdate;
const items = page.data.items || [];
const total = page.data.total || 0;
const toolbarActions = [
page.abilities.canUpdate
? { icon: <SyncAltOutlined fontSize="small" />, label: "BD Leader 状态", onClick: page.openBDStatusForm }
: null,
page.abilities.canCreate
? { icon: <Add fontSize="small" />, label: "创建 BD Leader", onClick: page.openBDLeaderForm, variant: "primary" }
: null
].filter(Boolean);
return (
<section className={styles.root}>
<div className={styles.contentPanel}>
<HostOrgToolbar
actions={toolbarActions}
filters={{
loadingRegions: page.loadingRegions,
query: page.query,
regionId: page.regionId,
regionOptions: page.regionOptions,
searchPlaceholder: "搜索用户 ID、展示 ID、用户名...",
status: page.status,
statusOptions: bdStatusFilters,
onQueryChange: page.changeQuery,
onRegionIdChange: page.changeRegionId,
onStatusChange: page.changeStatus
}}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}>
<HostOrgTable
columns={bdLeaderColumns}
items={items}
rowKey={(item) => `bd-leader-${item.userId}`}
/>
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
</div>
</DataState>
</div>
<HostOrgActionModal
disabled={createDisabled}
loading={page.loadingAction === "bd-leader"}
open={page.activeAction === "bd-leader"}
onClose={page.closeAction}
onSubmit={page.submitBDLeader}
title="创建 BD Leader"
>
<TextField disabled={createDisabled} label="用户短 ID" required type="number" value={page.bdLeaderForm.targetUserId} onChange={(event) => page.setBDLeaderForm({ ...page.bdLeaderForm, targetUserId: event.target.value })} />
<RegionSelect disabled={createDisabled} emptyLabel="选择区域" loading={page.loadingRegions} options={page.regionOptions} required value={page.bdLeaderForm.regionId} onChange={(value) => page.setBDLeaderForm({ ...page.bdLeaderForm, regionId: value })} />
<TextField disabled={createDisabled} label="联系方式" value={page.bdLeaderForm.reason} onChange={(event) => page.setBDLeaderForm({ ...page.bdLeaderForm, reason: event.target.value })} />
</HostOrgActionModal>
<HostOrgActionModal
disabled={updateDisabled}
loading={page.loadingAction === "bd-status"}
open={page.activeAction === "bd-status"}
onClose={page.closeAction}
onSubmit={page.submitBDStatus}
title="BD Leader 状态"
>
<TextField disabled={updateDisabled} label="用户 ID" required type="number" value={page.bdStatusForm.targetUserId} onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, targetUserId: event.target.value })} />
<TextField disabled={updateDisabled} label="状态" required select value={page.bdStatusForm.status} onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, status: event.target.value })}>
<MenuItem value="active">启用</MenuItem>
<MenuItem value="disabled">停用</MenuItem>
</TextField>
<TextField disabled={updateDisabled} label="原因" multiline minRows={2} value={page.bdStatusForm.reason} onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, reason: event.target.value })} />
</HostOrgActionModal>
</section>
);
}

Some files were not shown because too many files have changed in this diff Show More