完善功能
This commit is contained in:
parent
db964d7471
commit
f58c5d2e43
48
AGENTS.md
48
AGENTS.md
@ -24,24 +24,28 @@
|
|||||||
- UI 组件库:MUI。
|
- UI 组件库:MUI。
|
||||||
- 图表库:ECharts。
|
- 图表库:ECharts。
|
||||||
- 包管理器:pnpm。
|
- 包管理器:pnpm。
|
||||||
- 页面:`src/pages/OverviewPage.jsx`、`src/pages/ServicesPage.jsx` 和 `src/pages/UserManagementPage.jsx`。
|
- 页面:按业务放在 `src/features/*/pages/`。
|
||||||
- 应用壳:`src/App.jsx`,只做状态编排、懒加载页面、抽屉挂载和事件分发。
|
- 应用壳:`src/app/App.jsx`、`src/app/layout/AdminLayout.jsx`、`src/app/router/routeConfig.ts`。
|
||||||
- 页面加载占位:`src/components/layout/PageSkeleton.jsx`。
|
- 页面加载占位:`src/shared/ui/PageSkeleton.jsx`。
|
||||||
- 顶部搜索弹窗:`src/components/search/SearchDialog.jsx`。
|
- 顶部搜索弹窗:`src/features/search/components/SearchDialog.jsx`。
|
||||||
- MUI 主题:`src/theme.js`。
|
- MUI 主题:`src/theme.js`。
|
||||||
- CSS token:`src/styles/tokens.css`。
|
- 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/app/`:应用壳、Provider、鉴权上下文、路由守卫、统一路由配置和布局。
|
||||||
- `src/components/layout/`:布局组件。
|
- `src/shared/`:跨业务共享的 API 基础层、UI 组件、hooks、工具和图表封装。
|
||||||
- `src/components/charts/`:ECharts 封装和图表组件。
|
- `src/features/<module>/`:业务模块,内部放 `api.ts`、`routes.js`、`permissions.js`、`schema.ts`、`hooks/`、`components/`、`pages/`。
|
||||||
- `src/components/service/`:服务管理相关业务组件。
|
- 新增后台业务模块必须优先落在 `src/features/`,不要重新创建横向 `pages/`、`api/`、`components/` 目录。
|
||||||
- `src/pages/`:页面级模块。
|
- 新增模块优先复制 `admin-module.example.json`,改成业务配置后运行 `pnpm gen:module <config.json>`;它会生成 feature、权限常量、路由接入、OpenAPI 契约和后端 seed/routes 片段。
|
||||||
- `src/constants/`:导航、状态等常量。
|
- 只需要预览生成内容时运行 `pnpm gen:module <config.json> --dry-run`。
|
||||||
- `src/data/`:演示数据。
|
- `pnpm scaffold:feature <feature-name>` 是低层级骨架命令,只有在不需要权限/菜单/契约自动接入时使用。
|
||||||
- `src/utils/`:工具函数。
|
- 后端路由或权限变更后先运行 `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";
|
import { Refresh } from "@mui/icons-material";
|
||||||
```
|
```
|
||||||
|
|
||||||
- ECharts 只在 `src/components/charts/` 内封装和加载,不要在页面里直接散落图表初始化逻辑。
|
- ECharts 只在 `src/shared/charts/` 内封装和加载,不要在页面里直接散落图表初始化逻辑。
|
||||||
- 页面和抽屉继续使用 `React.lazy` 做拆分加载。
|
- 页面和抽屉继续使用 `React.lazy` 做拆分加载。
|
||||||
- 顶部搜索弹窗继续使用 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
|
```bash
|
||||||
|
pnpm check:contracts
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
pnpm test
|
||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
- 写代码不要猜:不确定的依赖、API、文件结构、现有约定,先查本地项目或依赖实际导出。
|
- 写代码不要猜:不确定的依赖、API、文件结构、现有约定,先查本地项目或依赖实际导出。
|
||||||
- 写前端或客户端项目时,不添加无意义的描述文案。
|
- 写前端或客户端项目时,不添加无意义的描述文案。
|
||||||
- 使用 `pnpm`。
|
- 使用 `pnpm`。
|
||||||
- 页面包含:`总览`、`服务管理` 和 `用户管理`。
|
- 页面包含:`总览`、`用户管理`、`房间管理`、`地区管理`、`团队管理` 等后台模块。
|
||||||
- 所有组件都要模块化,包括基础组件、布局组件、业务组件、图表组件。
|
- 所有组件都要模块化,包括基础组件、布局组件、业务组件、图表组件。
|
||||||
- 基础 UI 使用 MUI,但整体风格必须统一为白色后台管理平台风格。
|
- 基础 UI 使用 MUI,但整体风格必须统一为白色后台管理平台风格。
|
||||||
- 修改 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: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
"no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
"no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
||||||
"react-hooks/exhaustive-deps": "off",
|
|
||||||
"react-hooks/set-state-in-effect": "off",
|
"react-hooks/set-state-in-effect": "off",
|
||||||
"react-hooks/use-memo": "off",
|
"react-hooks/use-memo": "off",
|
||||||
"react-refresh/only-export-components": "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",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@9.15.9",
|
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build",
|
"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 .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
|
"scaffold:feature": "node scripts/scaffold-feature.mjs",
|
||||||
|
"sync:contract": "node scripts/sync-openapi-from-backend.mjs",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"preview": "vite preview --host 0.0.0.0"
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -17,22 +23,33 @@
|
|||||||
"@emotion/styled": "11.14.1",
|
"@emotion/styled": "11.14.1",
|
||||||
"@mui/icons-material": "9.0.0",
|
"@mui/icons-material": "9.0.0",
|
||||||
"@mui/material": "9.0.0",
|
"@mui/material": "9.0.0",
|
||||||
|
"@tanstack/react-query": "^5.100.6",
|
||||||
"echarts": "6.0.0",
|
"echarts": "6.0.0",
|
||||||
|
"libpag": "4.5.52",
|
||||||
"react": "19.2.5",
|
"react": "19.2.5",
|
||||||
"react-dom": "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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@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",
|
"@vitejs/plugin-react": "6.0.1",
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^10.2.1",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
"vite": "8.0.10",
|
"vite": "8.0.10",
|
||||||
"vitest": "^4.1.5"
|
"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 { render, screen } from "@testing-library/react";
|
||||||
import { expect, test } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
import { ThemeProvider } from "@mui/material/styles";
|
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { AuthProvider } from "./auth/AuthProvider.jsx";
|
import App from "@/app/App.jsx";
|
||||||
import { ConfirmProvider } from "./components/base/ConfirmProvider.jsx";
|
import { AppProviders } from "@/app/providers.jsx";
|
||||||
import { ToastProvider } from "./components/base/ToastProvider.jsx";
|
import { adminRoutes } from "@/app/router/routeConfig";
|
||||||
import { theme } from "./theme.js";
|
|
||||||
import App from "./App.jsx";
|
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 () => {
|
test("renders login route", async () => {
|
||||||
render(
|
renderWithRoute("/login");
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<ToastProvider>
|
|
||||||
<ConfirmProvider>
|
|
||||||
<MemoryRouter initialEntries={["/login"]}>
|
|
||||||
<AuthProvider>
|
|
||||||
<App />
|
|
||||||
</AuthProvider>
|
|
||||||
</MemoryRouter>
|
|
||||||
</ConfirmProvider>
|
|
||||||
</ToastProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await screen.findByText("HYApp 管理平台")).toBeInTheDocument();
|
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 { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
import * as authApi from "../api/auth.js";
|
import * as authApi from "@/features/auth/api";
|
||||||
import { getAccessToken, setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "../api/request.js";
|
import { getAccessToken, setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/shared/api/request";
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
@ -49,10 +49,23 @@ export function AuthProvider({ children }) {
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
try {
|
try {
|
||||||
if (getAccessToken()) {
|
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();
|
const session = await authApi.getMe();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
applySession(session);
|
applySession(session);
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await refresh();
|
await refresh();
|
||||||
@ -67,7 +80,7 @@ export function AuthProvider({ children }) {
|
|||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, [applySession, refresh]);
|
}, [applySession, clearSession, refresh]);
|
||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async (payload) => {
|
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 Search from "@mui/icons-material/Search";
|
||||||
import MuiMenu from "@mui/material/Menu";
|
import MuiMenu from "@mui/material/Menu";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
import { lazy, Suspense, useEffect, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { listNotifications } from "../../api/notifications.js";
|
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
|
||||||
import { useAuth } from "../../auth/AuthProvider.jsx";
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
import { IconButton } from "../base/IconButton.jsx";
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
import { useToast } from "../base/ToastProvider.jsx";
|
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 }) {
|
export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSidebar }) {
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const [searchMounted, setSearchMounted] = useState(false);
|
const [searchMounted, setSearchMounted] = useState(false);
|
||||||
const [userMenuAnchor, setUserMenuAnchor] = useState(null);
|
const [userMenuAnchor, setUserMenuAnchor] = useState(null);
|
||||||
const [unread, setUnread] = useState(0);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { logout, user } = useAuth();
|
const { can, logout, user } = useAuth();
|
||||||
|
const appScope = useAppScope();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const ToggleIcon = isSidebarCollapsed ? MenuOpen : Menu;
|
const ToggleIcon = isSidebarCollapsed ? MenuOpen : Menu;
|
||||||
const userMenuOpen = Boolean(userMenuAnchor);
|
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(() => {
|
useEffect(() => {
|
||||||
if (searchOpen) {
|
if (searchOpen) {
|
||||||
@ -64,30 +76,6 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
|
|||||||
navigate("/notifications");
|
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 = () => {
|
const refresh = () => {
|
||||||
onRefresh();
|
onRefresh();
|
||||||
showToast("已刷新当前页面", "success");
|
showToast("已刷新当前页面", "success");
|
||||||
@ -114,16 +102,36 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="header-right-tools">
|
<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}>
|
<button className="search-trigger" type="button" onClick={openSearch}>
|
||||||
<Search fontSize="small" />
|
<Search fontSize="small" />
|
||||||
<span>搜索用户、服务、IP...</span>
|
<span>搜索用户、角色...</span>
|
||||||
</button>
|
</button>
|
||||||
<IconButton label="刷新" onClick={refresh}>
|
<IconButton label="刷新" onClick={refresh}>
|
||||||
<Refresh fontSize="small" />
|
<Refresh fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
{canViewNotifications ? (
|
||||||
<IconButton label="通知" notice={unread ? String(unread) : undefined} onClick={openNotifications}>
|
<IconButton label="通知" notice={unread ? String(unread) : undefined} onClick={openNotifications}>
|
||||||
<NotificationsNoneOutlined fontSize="small" />
|
<NotificationsNoneOutlined fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
) : null}
|
||||||
<button className="user-pill" type="button" onClick={(event) => setUserMenuAnchor(event.currentTarget)}>
|
<button className="user-pill" type="button" onClick={(event) => setUserMenuAnchor(event.currentTarget)}>
|
||||||
<Person fontSize="small" />
|
<Person fontSize="small" />
|
||||||
<span>{user?.name || user?.account || "admin"}</span>
|
<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 TextField from "@mui/material/TextField";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../auth/AuthProvider.jsx";
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
import { Button } from "../components/base/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { useToast } from "../components/base/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const [username, setUsername] = useState("admin");
|
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