完善功能
This commit is contained in:
parent
db964d7471
commit
f58c5d2e43
48
AGENTS.md
48
AGENTS.md
@ -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
|
||||
```
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
- 写代码不要猜:不确定的依赖、API、文件结构、现有约定,先查本地项目或依赖实际导出。
|
||||
- 写前端或客户端项目时,不添加无意义的描述文案。
|
||||
- 使用 `pnpm`。
|
||||
- 页面包含:`总览`、`服务管理` 和 `用户管理`。
|
||||
- 页面包含:`总览`、`用户管理`、`房间管理`、`地区管理`、`团队管理` 等后台模块。
|
||||
- 所有组件都要模块化,包括基础组件、布局组件、业务组件、图表组件。
|
||||
- 基础 UI 使用 MUI,但整体风格必须统一为白色后台管理平台风格。
|
||||
- 修改 MUI 组件颜色和样式时注意兼容性,优先通过主题和封装组件统一处理。
|
||||
|
||||
25
admin-module.example.json
Normal file
25
admin-module.example.json
Normal 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
3191
contracts/admin-openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
21
package.json
21
package.json
@ -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
1146
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/vendor/libpag.wasm
vendored
Executable file
BIN
public/vendor/libpag.wasm
vendored
Executable file
Binary file not shown.
595
scripts/generate-admin-module.mjs
Normal file
595
scripts/generate-admin-module.mjs
Normal 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)])
|
||||
);
|
||||
}
|
||||
102
scripts/generate-api-client.mjs
Normal file
102
scripts/generate-api-client.mjs
Normal 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)}`);
|
||||
147
scripts/scaffold-feature.mjs
Normal file
147
scripts/scaffold-feature.mjs
Normal 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);
|
||||
}
|
||||
151
scripts/sync-openapi-from-backend.mjs
Normal file
151
scripts/sync-openapi-from-backend.mjs
Normal 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)])
|
||||
);
|
||||
}
|
||||
129
scripts/validate-admin-architecture.ts
Normal file
129
scripts/validate-admin-architecture.ts
Normal 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 || [])]);
|
||||
}
|
||||
155
src/App.jsx
155
src/App.jsx
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import { apiRequest } from "./request.js";
|
||||
|
||||
export function getDashboardOverview() {
|
||||
return apiRequest("/v1/dashboard/overview");
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import { apiRequest } from "./request.js";
|
||||
|
||||
export function getMenus() {
|
||||
return apiRequest("/v1/navigation/menus");
|
||||
}
|
||||
@ -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: {} });
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import { apiRequest } from "./request.js";
|
||||
|
||||
export function searchGlobal(keyword) {
|
||||
return apiRequest("/v1/search", { query: { keyword } });
|
||||
}
|
||||
@ -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: {} });
|
||||
}
|
||||
@ -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
34
src/app/App.jsx
Normal 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
41
src/app/ErrorBoundary.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
62
src/app/ErrorBoundary.module.css
Normal file
62
src/app/ErrorBoundary.module.css
Normal 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);
|
||||
}
|
||||
71
src/app/app-scope/AppScopeProvider.jsx
Normal file
71
src/app/app-scope/AppScopeProvider.jsx
Normal 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();
|
||||
}
|
||||
@ -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) => {
|
||||
58
src/app/layout/AdminLayout.jsx
Normal file
58
src/app/layout/AdminLayout.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
224
src/app/navigation/menu.js
Normal 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
|
||||
};
|
||||
}
|
||||
47
src/app/navigation/menu.test.jsx
Normal file
47
src/app/navigation/menu.test.jsx
Normal 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
117
src/app/permissions.ts
Normal 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
26
src/app/providers.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/app/router/DeferredPage.jsx
Normal file
35
src/app/router/DeferredPage.jsx
Normal 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
29
src/app/router/guards.jsx
Normal 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;
|
||||
}
|
||||
45
src/app/router/routeConfig.test.ts
Normal file
45
src/app/router/routeConfig.test.ts
Normal 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 || [])]);
|
||||
}
|
||||
38
src/app/router/routeConfig.ts
Normal file
38
src/app/router/routeConfig.ts
Normal 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
27
src/app/router/types.ts
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 = ["基础信息", "配置", "监控", "日志"];
|
||||
@ -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];
|
||||
18
src/features/app-config/api.ts
Normal file
18
src/features/app-config/api.ts
Normal 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
|
||||
});
|
||||
}
|
||||
7
src/features/app-config/app-config.module.css
Normal file
7
src/features/app-config/app-config.module.css
Normal file
@ -0,0 +1,7 @@
|
||||
.linkText {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
color: var(--text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
9
src/features/app-config/constants.ts
Normal file
9
src/features/app-config/constants.ts
Normal 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];
|
||||
68
src/features/app-config/hooks/useH5ConfigPage.js
Normal file
68
src/features/app-config/hooks/useH5ConfigPage.js
Normal 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
|
||||
};
|
||||
}
|
||||
97
src/features/app-config/pages/H5ConfigPage.jsx
Normal file
97
src/features/app-config/pages/H5ConfigPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/features/app-config/permissions.js
Normal file
11
src/features/app-config/permissions.js
Normal 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)
|
||||
};
|
||||
}
|
||||
12
src/features/app-config/routes.js
Normal file
12
src/features/app-config/routes.js
Normal 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
|
||||
}
|
||||
];
|
||||
15
src/features/app-config/schema.ts
Normal file
15
src/features/app-config/schema.ts
Normal 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>;
|
||||
10
src/features/app-registry/api.ts
Normal file
10
src/features/app-registry/api.ts
Normal 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
|
||||
});
|
||||
}
|
||||
51
src/features/app-users/api.ts
Normal file
51
src/features/app-users/api.ts
Normal 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
|
||||
}
|
||||
);
|
||||
}
|
||||
200
src/features/app-users/app-users.module.css
Normal file
200
src/features/app-users/app-users.module.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
18
src/features/app-users/constants.js
Normal file
18
src/features/app-users/constants.js
Normal file
@ -0,0 +1,18 @@
|
||||
export const appUserStatusFilters = [
|
||||
["", "全部"],
|
||||
["active", "正常"],
|
||||
["banned", "封禁"]
|
||||
];
|
||||
|
||||
export const appUserStatusLabels = {
|
||||
active: "正常",
|
||||
banned: "封禁",
|
||||
disabled: "封禁"
|
||||
};
|
||||
|
||||
export const genderOptions = [
|
||||
["", "未设置"],
|
||||
["male", "男"],
|
||||
["female", "女"],
|
||||
["unknown", "未知"]
|
||||
];
|
||||
197
src/features/app-users/hooks/useAppUsersPage.js
Normal file
197
src/features/app-users/hooks/useAppUsersPage.js
Normal 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";
|
||||
}
|
||||
266
src/features/app-users/pages/AppUserListPage.jsx
Normal file
266
src/features/app-users/pages/AppUserListPage.jsx
Normal 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 || "-";
|
||||
}
|
||||
14
src/features/app-users/permissions.js
Normal file
14
src/features/app-users/permissions.js
Normal 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)
|
||||
};
|
||||
}
|
||||
12
src/features/app-users/routes.js
Normal file
12
src/features/app-users/routes.js
Normal 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
|
||||
}
|
||||
];
|
||||
19
src/features/app-users/schema.ts
Normal file
19
src/features/app-users/schema.ts
Normal 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
37
src/features/auth/api.ts
Normal 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
|
||||
});
|
||||
}
|
||||
@ -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");
|
||||
8
src/features/auth/routes.js
Normal file
8
src/features/auth/routes.js
Normal file
@ -0,0 +1,8 @@
|
||||
export const authRoutes = [
|
||||
{
|
||||
label: "登录",
|
||||
loader: () => import("./pages/LoginPage.jsx").then((module) => module.LoginPage),
|
||||
pageKey: "login",
|
||||
path: "/login"
|
||||
}
|
||||
];
|
||||
7
src/features/dashboard/api.ts
Normal file
7
src/features/dashboard/api.ts
Normal 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));
|
||||
}
|
||||
16
src/features/dashboard/components/DashboardAlerts.jsx
Normal file
16
src/features/dashboard/components/DashboardAlerts.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/features/dashboard/components/DashboardCharts.jsx
Normal file
18
src/features/dashboard/components/DashboardCharts.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/features/dashboard/components/DashboardStats.jsx
Normal file
16
src/features/dashboard/components/DashboardStats.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/features/dashboard/components/DashboardToolbar.jsx
Normal file
16
src/features/dashboard/components/DashboardToolbar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
360
src/features/dashboard/dashboard.css
Normal file
360
src/features/dashboard/dashboard.css
Normal 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);
|
||||
}
|
||||
49
src/features/dashboard/hooks/useDashboardPage.js
Normal file
49
src/features/dashboard/hooks/useDashboardPage.js
Normal 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
|
||||
};
|
||||
}
|
||||
28
src/features/dashboard/pages/OverviewPage.jsx
Normal file
28
src/features/dashboard/pages/OverviewPage.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
src/features/dashboard/permissions.js
Normal file
3
src/features/dashboard/permissions.js
Normal file
@ -0,0 +1,3 @@
|
||||
export function useDashboardAbilities() {
|
||||
return {};
|
||||
}
|
||||
12
src/features/dashboard/routes.js
Normal file
12
src/features/dashboard/routes.js
Normal 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
|
||||
}
|
||||
];
|
||||
101
src/features/host-org/api.test.ts
Normal file
101
src/features/host-org/api.test.ts
Normal 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");
|
||||
});
|
||||
247
src/features/host-org/api.ts
Normal file
247
src/features/host-org/api.ts
Normal 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
|
||||
}
|
||||
);
|
||||
}
|
||||
29
src/features/host-org/components/HostOrgActionModal.jsx
Normal file
29
src/features/host-org/components/HostOrgActionModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/features/host-org/components/HostOrgFilters.jsx
Normal file
50
src/features/host-org/components/HostOrgFilters.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/features/host-org/components/HostOrgStatus.jsx
Normal file
23
src/features/host-org/components/HostOrgStatus.jsx
Normal 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";
|
||||
}
|
||||
5
src/features/host-org/components/HostOrgTable.jsx
Normal file
5
src/features/host-org/components/HostOrgTable.jsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
|
||||
export function HostOrgTable(props) {
|
||||
return <DataTable {...props} />;
|
||||
}
|
||||
46
src/features/host-org/components/HostOrgToolbar.jsx
Normal file
46
src/features/host-org/components/HostOrgToolbar.jsx
Normal 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)"
|
||||
}
|
||||
};
|
||||
47
src/features/host-org/constants.js
Normal file
47
src/features/host-org/constants.js
Normal 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: "停用"
|
||||
};
|
||||
28
src/features/host-org/hooks/useCountryOptions.js
Normal file
28
src/features/host-org/hooks/useCountryOptions.js
Normal 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(" · ");
|
||||
}
|
||||
178
src/features/host-org/hooks/useHostAgenciesPage.js
Normal file
178
src/features/host-org/hooks/useHostAgenciesPage.js
Normal 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()}`;
|
||||
}
|
||||
166
src/features/host-org/hooks/useHostBdsPage.js
Normal file
166
src/features/host-org/hooks/useHostBdsPage.js
Normal 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()}`;
|
||||
}
|
||||
198
src/features/host-org/hooks/useHostCoinSellersPage.js
Normal file
198
src/features/host-org/hooks/useHostCoinSellersPage.js
Normal 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()}`;
|
||||
}
|
||||
161
src/features/host-org/hooks/useHostCountriesPage.js
Normal file
161
src/features/host-org/hooks/useHostCountriesPage.js
Normal 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));
|
||||
});
|
||||
}
|
||||
75
src/features/host-org/hooks/useHostHostsPage.js
Normal file
75
src/features/host-org/hooks/useHostHostsPage.js
Normal 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
|
||||
};
|
||||
}
|
||||
175
src/features/host-org/hooks/useHostRegionsPage.js
Normal file
175
src/features/host-org/hooks/useHostRegionsPage.js
Normal 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));
|
||||
});
|
||||
}
|
||||
223
src/features/host-org/host-org.module.css
Normal file
223
src/features/host-org/host-org.module.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
142
src/features/host-org/pages/HostAgenciesPage.jsx
Normal file
142
src/features/host-org/pages/HostAgenciesPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
src/features/host-org/pages/HostBdLeadersPage.jsx
Normal file
103
src/features/host-org/pages/HostBdLeadersPage.jsx
Normal 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
Loading…
x
Reference in New Issue
Block a user