diff --git a/AGENTS.md b/AGENTS.md index 8676a71..027c99c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,24 +24,28 @@ - UI 组件库:MUI。 - 图表库:ECharts。 - 包管理器:pnpm。 -- 页面:`src/pages/OverviewPage.jsx`、`src/pages/ServicesPage.jsx` 和 `src/pages/UserManagementPage.jsx`。 -- 应用壳:`src/App.jsx`,只做状态编排、懒加载页面、抽屉挂载和事件分发。 -- 页面加载占位:`src/components/layout/PageSkeleton.jsx`。 -- 顶部搜索弹窗:`src/components/search/SearchDialog.jsx`。 +- 页面:按业务放在 `src/features/*/pages/`。 +- 应用壳:`src/app/App.jsx`、`src/app/layout/AdminLayout.jsx`、`src/app/router/routeConfig.ts`。 +- 页面加载占位:`src/shared/ui/PageSkeleton.jsx`。 +- 顶部搜索弹窗:`src/features/search/components/SearchDialog.jsx`。 - MUI 主题:`src/theme.js`。 - CSS token:`src/styles/tokens.css`。 -- 主要样式和动画:`src/styles/app.css`。 +- 样式入口:`src/styles/app.css`,实际样式按 `src/styles/*.css`、`src/features/*/*.css` 和 `*.module.css` 拆分。 +- 服务端状态:使用 `@tanstack/react-query`,共享入口在 `src/shared/query/` 和 `src/shared/hooks/useAdminQuery.js`。 +- 接口契约:`pnpm gen:api` 会先从 `../hyapp-server/server/admin` 路由同步 `contracts/admin-openapi.json`,再生成 `src/shared/api/generated/schema.d.ts` 和 `src/shared/api/generated/endpoints.ts`。 ## 目录规范 -- `src/components/base/`:项目基础组件封装,内部可以使用 MUI。 -- `src/components/layout/`:布局组件。 -- `src/components/charts/`:ECharts 封装和图表组件。 -- `src/components/service/`:服务管理相关业务组件。 -- `src/pages/`:页面级模块。 -- `src/constants/`:导航、状态等常量。 -- `src/data/`:演示数据。 -- `src/utils/`:工具函数。 +- `src/app/`:应用壳、Provider、鉴权上下文、路由守卫、统一路由配置和布局。 +- `src/shared/`:跨业务共享的 API 基础层、UI 组件、hooks、工具和图表封装。 +- `src/features//`:业务模块,内部放 `api.ts`、`routes.js`、`permissions.js`、`schema.ts`、`hooks/`、`components/`、`pages/`。 +- 新增后台业务模块必须优先落在 `src/features/`,不要重新创建横向 `pages/`、`api/`、`components/` 目录。 +- 新增模块优先复制 `admin-module.example.json`,改成业务配置后运行 `pnpm gen:module `;它会生成 feature、权限常量、路由接入、OpenAPI 契约和后端 seed/routes 片段。 +- 只需要预览生成内容时运行 `pnpm gen:module --dry-run`。 +- `pnpm scaffold:feature ` 是低层级骨架命令,只有在不需要权限/菜单/契约自动接入时使用。 +- 后端路由或权限变更后先运行 `pnpm gen:api`,只需要同步契约时运行 `pnpm sync:contract`。 +- 前端 API 路径必须从 `src/shared/api/generated/endpoints.ts` 取,不要在 feature API 中重新手写路径。 +- 表单提交前必须走模块内 `schema.ts` 或共享 schema 校验。 ## 按需导入规则 @@ -64,7 +68,7 @@ import { Button } from "@mui/material"; import { Refresh } from "@mui/icons-material"; ``` -- ECharts 只在 `src/components/charts/` 内封装和加载,不要在页面里直接散落图表初始化逻辑。 +- ECharts 只在 `src/shared/charts/` 内封装和加载,不要在页面里直接散落图表初始化逻辑。 - 页面和抽屉继续使用 `React.lazy` 做拆分加载。 - 顶部搜索弹窗继续使用 lazy 加载,不要静态塞进主入口。 @@ -80,11 +84,27 @@ import { Refresh } from "@mui/icons-material"; - 不添加卡片套卡片。 - 不添加无业务意义的说明区、营销区、空泛介绍区。 +## 后台表格交互规则 + +- 表格空态只显示 `当前无数据`,创建入口放在页面工具栏,不放在空态里。 +- 表格里的启用/禁用状态优先使用行内 Switch 快捷操作,不使用单独的启用/禁用图标按钮。 +- 表格状态筛选使用 Select 下拉框,默认值为 `全部状态`,不使用状态 chip 组。 +- 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。 +- 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。 +- 图片上传表单必须提供统一预览;`webp` 使用浏览器原生动效预览,`svga` 使用 SVGA 播放器预览,`pag` 使用 libpag + wasm 预览。 +- BD Leader 创建弹窗必须使用区域下拉选择 `regionId`;目标用户原区域可以不同,创建成功后后端会把目标用户当前区域同步到所选区域。 +- 区域创建和区域启用/禁用调用后台管理后端接口,不要求 user-service 新增对应 RPC。 +- 国家创建和国家启用/禁用调用后台管理后端接口,不要求 user-service 新增对应 RPC。 + ## 验证要求 每次完成前端改动后至少运行: ```bash +pnpm check:contracts +pnpm typecheck +pnpm lint +pnpm test pnpm build ``` diff --git a/README.md b/README.md index b55f435..d8e4603 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - 写代码不要猜:不确定的依赖、API、文件结构、现有约定,先查本地项目或依赖实际导出。 - 写前端或客户端项目时,不添加无意义的描述文案。 - 使用 `pnpm`。 -- 页面包含:`总览`、`服务管理` 和 `用户管理`。 +- 页面包含:`总览`、`用户管理`、`房间管理`、`地区管理`、`团队管理` 等后台模块。 - 所有组件都要模块化,包括基础组件、布局组件、业务组件、图表组件。 - 基础 UI 使用 MUI,但整体风格必须统一为白色后台管理平台风格。 - 修改 MUI 组件颜色和样式时注意兼容性,优先通过主题和封装组件统一处理。 diff --git a/admin-module.example.json b/admin-module.example.json new file mode 100644 index 0000000..8c4da9d --- /dev/null +++ b/admin-module.example.json @@ -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 } + ] +} diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json new file mode 100644 index 0000000..bf82489 --- /dev/null +++ b/contracts/admin-openapi.json @@ -0,0 +1,3191 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "HYApp Admin API", + "version": "0.1.0" + }, + "servers": [ + { + "url": "/api/v1" + } + ], + "paths": { + "/admin/agencies": { + "get": { + "operationId": "listAgencies", + "parameters": [ + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/PageSize" + }, + { + "$ref": "#/components/parameters/Keyword" + }, + { + "$ref": "#/components/parameters/Status" + }, + { + "$ref": "#/components/parameters/RegionId" + }, + { + "$ref": "#/components/parameters/ParentBdUserId" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/AgencyPageResponse" + } + }, + "x-permission": "agency:view", + "x-permissions": [ + "agency:view" + ] + }, + "post": { + "operationId": "createAgency", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "agency:create", + "x-permissions": [ + "agency:create" + ] + } + }, + "/admin/agencies/{agency_id}/close": { + "post": { + "operationId": "closeAgency", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "agency_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "agency:status", + "x-permissions": [ + "agency:status" + ] + } + }, + "/admin/agencies/{agency_id}/join-enabled": { + "post": { + "operationId": "setAgencyJoinEnabled", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "agency_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "agency:status", + "x-permissions": [ + "agency:status" + ] + } + }, + "/admin/app-config/h5-links": { + "get": { + "operationId": "listH5Links", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "app-config:view", + "x-permissions": [ + "app-config:view" + ] + }, + "put": { + "operationId": "updateH5Links", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "app-config:update", + "x-permissions": [ + "app-config:update" + ] + } + }, + "/admin/apps": { + "get": { + "operationId": "listApps", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + } + } + }, + "/admin/bd-leaders": { + "get": { + "operationId": "listBDLeaders", + "parameters": [ + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/PageSize" + }, + { + "$ref": "#/components/parameters/Keyword" + }, + { + "$ref": "#/components/parameters/Status" + }, + { + "$ref": "#/components/parameters/RegionId" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/BDProfilePageResponse" + } + }, + "x-permission": "bd:view", + "x-permissions": [ + "bd:view" + ] + }, + "post": { + "operationId": "createBDLeader", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "bd:create", + "x-permissions": [ + "bd:create" + ] + } + }, + "/admin/bd-leaders/{user_id}/status": { + "patch": { + "operationId": "setBDLeaderStatus", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "bd:update", + "x-permissions": [ + "bd:update" + ] + } + }, + "/admin/bds": { + "get": { + "operationId": "listBDs", + "parameters": [ + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/PageSize" + }, + { + "$ref": "#/components/parameters/Keyword" + }, + { + "$ref": "#/components/parameters/Status" + }, + { + "$ref": "#/components/parameters/RegionId" + }, + { + "$ref": "#/components/parameters/ParentLeaderUserId" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/BDProfilePageResponse" + } + }, + "x-permission": "bd:view", + "x-permissions": [ + "bd:view" + ] + }, + "post": { + "operationId": "createBD", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "bd:create", + "x-permissions": [ + "bd:create" + ] + } + }, + "/admin/bds/{user_id}/status": { + "patch": { + "operationId": "setBDStatus", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "bd:update", + "x-permissions": [ + "bd:update" + ] + } + }, + "/admin/coin-sellers": { + "get": { + "operationId": "listCoinSellers", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "coin-seller:view", + "x-permissions": [ + "coin-seller:view" + ] + }, + "post": { + "operationId": "createCoinSeller", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "coin-seller:create", + "x-permissions": [ + "coin-seller:create" + ] + } + }, + "/admin/coin-sellers/{user_id}/status": { + "patch": { + "operationId": "setCoinSellerStatus", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "coin-seller:update", + "x-permissions": [ + "coin-seller:update" + ] + } + }, + "/admin/countries": { + "get": { + "operationId": "listCountries", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "country:view", + "x-permissions": [ + "country:view" + ] + }, + "post": { + "operationId": "createCountry", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "country:create", + "x-permissions": [ + "country:create" + ] + } + }, + "/admin/countries/{country_id}": { + "get": { + "operationId": "getCountry", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "country_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "country:view", + "x-permissions": [ + "country:view" + ] + }, + "patch": { + "operationId": "updateCountry", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "country_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "country:update", + "x-permissions": [ + "country:update" + ] + }, + "delete": { + "operationId": "deleteCountry", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "country_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "country:status", + "x-permissions": [ + "country:status" + ] + } + }, + "/admin/countries/{country_id}/disable": { + "post": { + "operationId": "disableCountry", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "country_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "country:status", + "x-permissions": [ + "country:status" + ] + } + }, + "/admin/countries/{country_id}/enable": { + "post": { + "operationId": "enableCountry", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "country_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "country:status", + "x-permissions": [ + "country:status" + ] + } + }, + "/admin/files/image/upload": { + "post": { + "operationId": "uploadImage", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "upload:create", + "x-permissions": [ + "upload:create" + ] + } + }, + "/admin/files/upload": { + "post": { + "operationId": "uploadFile", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "upload:create", + "x-permissions": [ + "upload:create" + ] + } + }, + "/admin/gifts": { + "get": { + "operationId": "listGifts", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "gift:view", + "x-permissions": [ + "gift:view" + ] + }, + "post": { + "operationId": "createGift", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "gift:create", + "x-permissions": [ + "gift:create" + ] + } + }, + "/admin/gifts/{gift_id}": { + "put": { + "operationId": "updateGift", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "gift_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "gift:update", + "x-permissions": [ + "gift:update" + ] + } + }, + "/admin/gifts/{gift_id}/disable": { + "post": { + "operationId": "disableGift", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "gift_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "gift:status", + "x-permissions": [ + "gift:status" + ] + } + }, + "/admin/gifts/{gift_id}/enable": { + "post": { + "operationId": "enableGift", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "gift_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "gift:status", + "x-permissions": [ + "gift:status" + ] + } + }, + "/admin/hosts": { + "get": { + "operationId": "listHosts", + "parameters": [ + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/PageSize" + }, + { + "$ref": "#/components/parameters/Keyword" + }, + { + "$ref": "#/components/parameters/Status" + }, + { + "$ref": "#/components/parameters/RegionId" + }, + { + "$ref": "#/components/parameters/AgencyId" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/HostProfilePageResponse" + } + }, + "x-permission": "host:view", + "x-permissions": [ + "host:view" + ] + } + }, + "/admin/regions": { + "get": { + "operationId": "listRegions", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "region:view", + "x-permissions": [ + "region:view" + ] + }, + "post": { + "operationId": "createRegion", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "region:create", + "x-permissions": [ + "region:create" + ] + } + }, + "/admin/regions/{region_id}": { + "get": { + "operationId": "getRegion", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "region_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "region:view", + "x-permissions": [ + "region:view" + ] + }, + "patch": { + "operationId": "updateRegion", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "region_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "region:update", + "x-permissions": [ + "region:update" + ] + }, + "delete": { + "operationId": "disableRegion", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "region_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "region:status", + "x-permissions": [ + "region:status" + ] + } + }, + "/admin/regions/{region_id}/countries": { + "put": { + "operationId": "replaceRegionCountries", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "region_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "region:update", + "x-permissions": [ + "region:update" + ] + } + }, + "/admin/regions/{region_id}/enable": { + "post": { + "operationId": "enableRegion", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "region_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "region:status", + "x-permissions": [ + "region:status" + ] + } + }, + "/admin/resource-grants": { + "get": { + "operationId": "listResourceGrants", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "resource-grant:view", + "x-permissions": [ + "resource-grant:view" + ] + } + }, + "/admin/resource-grants/group": { + "post": { + "operationId": "grantResourceGroup", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "resource-grant:create", + "x-permissions": [ + "resource-grant:create" + ] + } + }, + "/admin/resource-grants/resource": { + "post": { + "operationId": "grantResource", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "resource-grant:create", + "x-permissions": [ + "resource-grant:create" + ] + } + }, + "/admin/resource-groups": { + "get": { + "operationId": "listResourceGroups", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "resource-group:view", + "x-permissions": [ + "resource-group:view" + ] + }, + "post": { + "operationId": "createResourceGroup", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "resource-group:create", + "x-permissions": [ + "resource-group:create" + ] + } + }, + "/admin/resource-groups/{group_id}": { + "get": { + "operationId": "getResourceGroup", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "group_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "resource-group:view", + "x-permissions": [ + "resource-group:view" + ] + }, + "put": { + "operationId": "updateResourceGroup", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "group_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "resource-group:update", + "x-permissions": [ + "resource-group:update" + ] + } + }, + "/admin/resource-groups/{group_id}/disable": { + "post": { + "operationId": "disableResourceGroup", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "group_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "resource-group:update", + "x-permissions": [ + "resource-group:update" + ] + } + }, + "/admin/resource-groups/{group_id}/enable": { + "post": { + "operationId": "enableResourceGroup", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "group_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "resource-group:update", + "x-permissions": [ + "resource-group:update" + ] + } + }, + "/admin/resource-groups/{group_id}/items": { + "put": { + "operationId": "updateResourceGroupItems", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "group_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "resource-group:update", + "x-permissions": [ + "resource-group:update" + ] + } + }, + "/admin/resources": { + "get": { + "operationId": "listResources", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "resource:view", + "x-permissions": [ + "resource:view" + ] + }, + "post": { + "operationId": "createResource", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "resource:create", + "x-permissions": [ + "resource:create" + ] + } + }, + "/admin/resources/{resource_id}": { + "get": { + "operationId": "getResource", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "resource_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "resource:view", + "x-permissions": [ + "resource:view" + ] + }, + "put": { + "operationId": "updateResource", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "resource_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "resource:update", + "x-permissions": [ + "resource:update" + ] + } + }, + "/admin/resources/{resource_id}/disable": { + "post": { + "operationId": "disableResource", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "resource_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "resource:update", + "x-permissions": [ + "resource:update" + ] + } + }, + "/admin/resources/{resource_id}/enable": { + "post": { + "operationId": "enableResource", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "resource_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "resource:update", + "x-permissions": [ + "resource:update" + ] + } + }, + "/admin/rooms": { + "get": { + "operationId": "listRooms", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "room:view", + "x-permissions": [ + "room:view" + ] + } + }, + "/admin/rooms/{room_id}": { + "patch": { + "operationId": "updateRoom", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "room_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "room:update", + "x-permissions": [ + "room:update" + ] + }, + "delete": { + "operationId": "deleteRoom", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "room_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "room:delete", + "x-permissions": [ + "room:delete" + ] + } + }, + "/app/users": { + "get": { + "operationId": "appListUsers", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "app-user:view", + "x-permissions": [ + "app-user:view" + ] + } + }, + "/app/users/{id}": { + "get": { + "operationId": "appGetUser", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "x-permission": "app-user:view", + "x-permissions": [ + "app-user:view" + ] + }, + "patch": { + "operationId": "appUpdateUser", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "x-permission": "app-user:update", + "x-permissions": [ + "app-user:update" + ] + } + }, + "/app/users/{id}/ban": { + "post": { + "operationId": "appBanUser", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "x-permission": "app-user:status", + "x-permissions": [ + "app-user:status" + ] + } + }, + "/app/users/{id}/password": { + "post": { + "operationId": "appSetPassword", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "x-permission": "app-user:password", + "x-permissions": [ + "app-user:password" + ] + } + }, + "/app/users/{id}/unban": { + "post": { + "operationId": "appUnbanUser", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "x-permission": "app-user:status", + "x-permissions": [ + "app-user:status" + ] + } + }, + "/auth/change-password": { + "post": { + "operationId": "changePassword", + "requestBody": { + "$ref": "#/components/requestBodies/ChangePasswordRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + } + } + }, + "/auth/login": { + "post": { + "operationId": "login", + "requestBody": { + "$ref": "#/components/requestBodies/LoginRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/SessionResponse" + } + } + } + }, + "/auth/logout": { + "post": { + "operationId": "logout", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + } + } + }, + "/auth/me": { + "get": { + "operationId": "me", + "responses": { + "200": { + "$ref": "#/components/responses/SessionResponse" + } + } + } + }, + "/auth/refresh": { + "post": { + "operationId": "refresh", + "responses": { + "200": { + "$ref": "#/components/responses/SessionResponse" + } + } + } + }, + "/dashboard/overview": { + "get": { + "operationId": "dashboardOverview", + "x-permission": "overview:view", + "responses": { + "200": { + "$ref": "#/components/responses/DashboardOverviewResponse" + } + }, + "x-permissions": [ + "overview:view" + ] + } + }, + "/exports/users": { + "post": { + "operationId": "createUserExportJob", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "export:create", + "x-permissions": [ + "export:create" + ] + } + }, + "/jobs": { + "get": { + "operationId": "listJobs", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "job:view", + "x-permissions": [ + "job:view" + ] + } + }, + "/jobs/{id}/artifact": { + "get": { + "operationId": "downloadJobArtifact", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "x-permission": "job:view", + "x-permissions": [ + "job:view" + ] + } + }, + "/jobs/{id}/cancel": { + "post": { + "operationId": "cancelJob", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "x-permission": "job:cancel", + "x-permissions": [ + "job:cancel" + ] + } + }, + "/logs/login": { + "get": { + "operationId": "listLoginLogs", + "x-permission": "log:view", + "parameters": [ + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/PageSize" + }, + { + "$ref": "#/components/parameters/Keyword" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/LogPageResponse" + } + }, + "x-permissions": [ + "log:view" + ] + } + }, + "/logs/login/export": { + "get": { + "operationId": "exportLoginLogs", + "x-permission": "log:export", + "responses": { + "200": { + "description": "CSV" + } + }, + "x-permissions": [ + "log:export" + ] + } + }, + "/logs/operations": { + "get": { + "operationId": "listOperationLogs", + "x-permission": "log:view", + "parameters": [ + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/PageSize" + }, + { + "$ref": "#/components/parameters/Keyword" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/LogPageResponse" + } + }, + "x-permissions": [ + "log:view" + ] + } + }, + "/logs/operations/export": { + "get": { + "operationId": "exportOperationLogs", + "x-permission": "log:export", + "responses": { + "200": { + "description": "CSV" + } + }, + "x-permissions": [ + "log:export" + ] + } + }, + "/navigation/menus": { + "get": { + "operationId": "navigationMenus", + "responses": { + "200": { + "$ref": "#/components/responses/MenuListResponse" + } + } + } + }, + "/notifications": { + "get": { + "operationId": "listNotifications", + "x-permission": "notification:view", + "responses": { + "200": { + "$ref": "#/components/responses/NotificationListResponse" + } + }, + "x-permissions": [ + "notification:view" + ] + } + }, + "/notifications/{id}": { + "delete": { + "operationId": "deleteNotification", + "x-permission": "notification:delete", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permissions": [ + "notification:delete" + ] + } + }, + "/notifications/{id}/read": { + "patch": { + "operationId": "markNotificationRead", + "x-permission": "notification:read", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/NotificationResponse" + } + }, + "x-permissions": [ + "notification:read" + ] + } + }, + "/notifications/read-all": { + "patch": { + "operationId": "markAllNotificationsRead", + "x-permission": "notification:read-all", + "responses": { + "200": { + "$ref": "#/components/responses/NotificationListResponse" + } + }, + "x-permissions": [ + "notification:read-all" + ] + } + }, + "/permissions": { + "get": { + "operationId": "listPermissions", + "x-permission": "permission:view", + "responses": { + "200": { + "$ref": "#/components/responses/PermissionListResponse" + } + }, + "x-permissions": [ + "permission:view", + "role:permission", + "role:manage" + ] + }, + "post": { + "operationId": "createPermission", + "x-permission": "permission:create", + "requestBody": { + "$ref": "#/components/requestBodies/PermissionRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/PermissionResponse" + } + }, + "x-permissions": [ + "permission:create" + ] + } + }, + "/permissions/{id}": { + "patch": { + "operationId": "updatePermission", + "x-permission": "permission:update", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/PermissionRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/PermissionResponse" + } + }, + "x-permissions": [ + "permission:update" + ] + }, + "delete": { + "operationId": "deletePermission", + "x-permission": "permission:delete", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permissions": [ + "permission:delete" + ] + } + }, + "/permissions/sync": { + "post": { + "operationId": "syncPermissions", + "x-permission": "permission:sync", + "responses": { + "200": { + "$ref": "#/components/responses/PermissionListResponse" + } + }, + "x-permissions": [ + "permission:sync" + ] + } + }, + "/roles": { + "get": { + "operationId": "listRoles", + "x-permission": "role:view", + "responses": { + "200": { + "$ref": "#/components/responses/RoleListResponse" + } + }, + "x-permissions": [ + "role:view" + ] + }, + "post": { + "operationId": "createRole", + "x-permission": "role:create", + "requestBody": { + "$ref": "#/components/requestBodies/RoleRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/RoleResponse" + } + }, + "x-permissions": [ + "role:create", + "role:manage" + ] + } + }, + "/roles/{id}": { + "patch": { + "operationId": "updateRole", + "x-permission": "role:update", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/RoleRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/RoleResponse" + } + }, + "x-permissions": [ + "role:update", + "role:manage" + ] + }, + "delete": { + "operationId": "deleteRole", + "x-permission": "role:delete", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permissions": [ + "role:delete", + "role:manage" + ] + } + }, + "/roles/{id}/data-scopes": { + "get": { + "operationId": "getRoleDataScopes", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "x-permission": "role:data-scope", + "x-permissions": [ + "role:data-scope" + ] + }, + "put": { + "operationId": "replaceRoleDataScopes", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "x-permission": "role:data-scope", + "x-permissions": [ + "role:data-scope" + ] + } + }, + "/roles/{id}/permissions": { + "put": { + "operationId": "replaceRolePermissions", + "x-permission": "role:permission", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/RolePermissionRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/RoleResponse" + } + }, + "x-permissions": [ + "role:permission", + "role:manage" + ] + } + }, + "/search": { + "get": { + "operationId": "search", + "parameters": [ + { + "$ref": "#/components/parameters/Keyword" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/SearchResponse" + } + } + } + }, + "/system/menus": { + "get": { + "operationId": "listSystemMenus", + "x-permission": "menu:view", + "responses": { + "200": { + "$ref": "#/components/responses/MenuListResponse" + } + }, + "x-permissions": [ + "menu:view" + ] + }, + "post": { + "operationId": "createMenu", + "x-permission": "menu:create", + "requestBody": { + "$ref": "#/components/requestBodies/MenuRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/MenuResponse" + } + }, + "x-permissions": [ + "menu:create" + ] + } + }, + "/system/menus/{id}": { + "patch": { + "operationId": "updateMenu", + "x-permission": "menu:update", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/MenuRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/MenuResponse" + } + }, + "x-permissions": [ + "menu:update" + ] + }, + "delete": { + "operationId": "deleteMenu", + "x-permission": "menu:delete", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permissions": [ + "menu:delete" + ] + } + }, + "/system/menus/{id}/visible": { + "patch": { + "operationId": "updateMenuVisible", + "x-permission": "menu:visible", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/MenuVisibleRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/MenuResponse" + } + }, + "x-permissions": [ + "menu:visible" + ] + } + }, + "/system/menus/sort": { + "put": { + "operationId": "sortMenus", + "x-permission": "menu:sort", + "requestBody": { + "$ref": "#/components/requestBodies/MenuSortRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/MenuListResponse" + } + }, + "x-permissions": [ + "menu:sort" + ] + } + }, + "/users": { + "get": { + "operationId": "listUsers", + "x-permission": "user:view", + "parameters": [ + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/PageSize" + }, + { + "$ref": "#/components/parameters/Keyword" + }, + { + "name": "role", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Status" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/UserPageResponse" + } + }, + "x-permissions": [ + "user:view" + ] + }, + "post": { + "operationId": "createUser", + "x-permission": "user:create", + "requestBody": { + "$ref": "#/components/requestBodies/UserRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/CreateUserResponse" + } + }, + "x-permissions": [ + "user:create" + ] + } + }, + "/users/{id}": { + "get": { + "operationId": "getUser", + "x-permission": "user:view", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/UserResponse" + } + }, + "x-permissions": [ + "user:view" + ] + }, + "patch": { + "operationId": "updateUser", + "x-permission": "user:update", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/UserRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/UserResponse" + } + }, + "x-permissions": [ + "user:update" + ] + } + }, + "/users/{id}/reset-password": { + "post": { + "operationId": "resetUserPassword", + "x-permission": "user:reset-password", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ResetPasswordResponse" + } + }, + "x-permissions": [ + "user:reset-password" + ] + } + }, + "/users/{id}/status": { + "patch": { + "operationId": "updateUserStatus", + "x-permission": "user:status", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/StatusRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/UserResponse" + } + }, + "x-permissions": [ + "user:status" + ] + } + }, + "/users/batch/status": { + "post": { + "operationId": "batchUpdateUserStatus", + "x-permission": "user:status", + "requestBody": { + "$ref": "#/components/requestBodies/BatchStatusRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permissions": [ + "user:status" + ] + } + }, + "/users/export": { + "get": { + "operationId": "exportUsers", + "x-permission": "user:export", + "responses": { + "200": { + "description": "CSV" + } + }, + "x-permissions": [ + "user:export" + ] + } + } + }, + "components": { + "parameters": { + "AgencyId": { + "name": "agency_id", + "in": "query", + "schema": { + "type": "integer" + } + }, + "Id": { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + "Keyword": { + "name": "keyword", + "in": "query", + "schema": { + "type": "string" + } + }, + "Page": { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + "PageSize": { + "name": "page_size", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + "ParentBdUserId": { + "name": "parent_bd_user_id", + "in": "query", + "schema": { + "type": "integer" + } + }, + "ParentLeaderUserId": { + "name": "parent_leader_user_id", + "in": "query", + "schema": { + "type": "integer" + } + }, + "RegionId": { + "name": "region_id", + "in": "query", + "schema": { + "type": "integer" + } + }, + "Status": { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + }, + "requestBodies": { + "BatchStatusRequest": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ids", + "status" + ], + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "status": { + "type": "string" + } + } + } + } + } + }, + "ChangePasswordRequest": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "oldPassword", + "newPassword" + ], + "properties": { + "oldPassword": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + } + } + } + }, + "LoginRequest": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + } + } + } + } + }, + "MenuRequest": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MenuInput" + } + } + } + }, + "MenuSortRequest": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "sort" + ], + "properties": { + "id": { + "type": "integer" + }, + "sort": { + "type": "integer" + } + } + } + } + } + } + } + } + }, + "MenuVisibleRequest": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "visible" + ], + "properties": { + "visible": { + "type": "boolean" + } + } + } + } + } + }, + "PermissionRequest": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PermissionInput" + } + } + } + }, + "RolePermissionRequest": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "permissionIds" + ], + "properties": { + "permissionIds": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + } + } + }, + "RoleRequest": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoleInput" + } + } + } + }, + "StatusRequest": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string" + } + } + } + } + } + }, + "UserRequest": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserInput" + } + } + } + } + }, + "responses": { + "AgencyPageResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAgencyPage" + } + } + } + }, + "BDProfilePageResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseBDProfilePage" + } + } + } + }, + "CreateUserResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCreateUserResult" + } + } + } + }, + "DashboardOverviewResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseDashboardOverview" + } + } + } + }, + "EmptyResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseEmpty" + } + } + } + }, + "HostProfilePageResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseHostProfilePage" + } + } + } + }, + "LogPageResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseLogPage" + } + } + } + }, + "MenuListResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMenuList" + } + } + } + }, + "MenuResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMenu" + } + } + } + }, + "NotificationListResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseNotificationList" + } + } + } + }, + "NotificationResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseNotification" + } + } + } + }, + "PermissionListResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponsePermissionList" + } + } + } + }, + "PermissionResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponsePermission" + } + } + } + }, + "ResetPasswordResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseResetPasswordResult" + } + } + } + }, + "RoleListResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseRoleList" + } + } + } + }, + "RoleResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseRole" + } + } + } + }, + "SearchResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseSearchList" + } + } + } + }, + "SessionResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseSession" + } + } + } + }, + "UserPageResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseUserPage" + } + } + } + }, + "UserResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseUser" + } + } + } + } + }, + "schemas": { + "ApiPageAgency": { + "type": "object", + "required": [ + "items", + "page", + "pageSize", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Agency" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "ApiPageBDProfile": { + "type": "object", + "required": [ + "items", + "page", + "pageSize", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BDProfile" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "ApiPageHostProfile": { + "type": "object", + "required": [ + "items", + "page", + "pageSize", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HostProfile" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "ApiPageLog": { + "type": "object", + "required": [ + "items", + "page", + "pageSize", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Log" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "ApiPageUser": { + "type": "object", + "required": [ + "items", + "page", + "pageSize", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AdminUser" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "ApiResponseCreateUserResult": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/CreateUserResult" + } + } + } + ] + }, + "ApiResponseAgencyPage": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ApiPageAgency" + } + } + } + ] + }, + "ApiResponseBDProfilePage": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ApiPageBDProfile" + } + } + } + ] + }, + "ApiResponseDashboardOverview": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/DashboardOverview" + } + } + } + ] + }, + "ApiResponseEmpty": { + "$ref": "#/components/schemas/Envelope" + }, + "ApiResponseHostProfilePage": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ApiPageHostProfile" + } + } + } + ] + }, + "ApiResponseLogPage": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ApiPageLog" + } + } + } + ] + }, + "ApiResponseMenu": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Menu" + } + } + } + ] + }, + "ApiResponseMenuList": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Menu" + } + } + } + } + ] + }, + "ApiResponseNotification": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Notification" + } + } + } + ] + }, + "ApiResponseNotificationList": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/NotificationList" + } + } + } + ] + }, + "ApiResponsePermission": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Permission" + } + } + } + ] + }, + "ApiResponsePermissionList": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Permission" + } + } + } + } + ] + }, + "ApiResponseResetPasswordResult": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ResetPasswordResult" + } + } + } + ] + }, + "ApiResponseRole": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Role" + } + } + } + ] + }, + "ApiResponseRoleList": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Role" + } + } + } + } + ] + }, + "ApiResponseSearchList": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchResult" + } + } + } + } + ] + }, + "ApiResponseSession": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Session" + } + } + } + ] + }, + "ApiResponseUser": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/AdminUser" + } + } + } + ] + }, + "ApiResponseUserPage": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ApiPageUser" + } + } + } + ] + }, + "AdminUser": { + "type": "object", + "required": [ + "account", + "id", + "name", + "status" + ], + "properties": { + "account": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "lastLogin": { + "type": "string" + }, + "mfa": { + "type": "string" + }, + "mfaEnabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "roleIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "status": { + "type": "string" + }, + "team": { + "type": "string" + } + } + }, + "Agency": { + "type": "object", + "required": [ + "agencyId" + ], + "properties": { + "agencyId": { + "type": "integer" + }, + "createdAtMs": { + "type": "integer" + }, + "createdByUserId": { + "type": "integer" + }, + "joinEnabled": { + "type": "boolean" + }, + "maxHosts": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "ownerUserId": { + "type": "integer" + }, + "parentBdUserId": { + "type": "integer" + }, + "regionId": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "updatedAtMs": { + "type": "integer" + } + } + }, + "BDProfile": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "createdAtMs": { + "type": "integer" + }, + "createdByUserId": { + "type": "integer" + }, + "parentLeaderUserId": { + "type": "integer" + }, + "regionId": { + "type": "integer" + }, + "role": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updatedAtMs": { + "type": "integer" + }, + "userId": { + "type": "integer" + } + } + }, + "CreateUserResult": { + "type": "object", + "properties": { + "initialPassword": { + "type": "string" + } + } + }, + "DashboardOverview": { + "type": "object", + "additionalProperties": true + }, + "Envelope": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "data": {}, + "message": { + "type": "string" + } + } + }, + "HostProfile": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "createdAtMs": { + "type": "integer" + }, + "currentAgencyId": { + "type": "integer" + }, + "currentMembershipId": { + "type": "integer" + }, + "firstBecameHostAtMs": { + "type": "integer" + }, + "regionId": { + "type": "integer" + }, + "source": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updatedAtMs": { + "type": "integer" + }, + "userId": { + "type": "integer" + } + } + }, + "Log": { + "type": "object", + "additionalProperties": true + }, + "Menu": { + "type": "object", + "additionalProperties": true + }, + "MenuInput": { + "type": "object", + "required": [ + "code", + "label" + ], + "additionalProperties": true + }, + "Notification": { + "type": "object", + "additionalProperties": true + }, + "NotificationList": { + "type": "object", + "required": [ + "items", + "unread" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Notification" + } + }, + "unread": { + "type": "integer" + } + } + }, + "Permission": { + "type": "object", + "additionalProperties": true + }, + "PermissionInput": { + "type": "object", + "required": [ + "code", + "kind", + "name" + ], + "additionalProperties": true + }, + "ResetPasswordResult": { + "type": "object", + "properties": { + "initialPassword": { + "type": "string" + } + } + }, + "Role": { + "type": "object", + "additionalProperties": true + }, + "RoleInput": { + "type": "object", + "required": [ + "code", + "name" + ], + "additionalProperties": true + }, + "SearchResult": { + "type": "object", + "additionalProperties": true + }, + "Session": { + "type": "object", + "additionalProperties": true + }, + "UserInput": { + "type": "object", + "required": [ + "name" + ], + "additionalProperties": true + } + } + } +} diff --git a/eslint.config.js b/eslint.config.js index 5619615..63cbe5b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -23,10 +23,17 @@ export default [ rules: { ...reactHooks.configs.recommended.rules, "no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], - "react-hooks/exhaustive-deps": "off", "react-hooks/set-state-in-effect": "off", "react-hooks/use-memo": "off", "react-refresh/only-export-components": "off" } + }, + { + files: ["scripts/**/*.{js,mjs}"], + languageOptions: { + ecmaVersion: "latest", + globals: globals.node, + sourceType: "module" + } } ]; diff --git a/package.json b/package.json index c7f6ae7..1726df6 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,19 @@ "version": "0.1.0", "private": true, "type": "module", - "packageManager": "pnpm@9.15.9", + "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8", "scripts": { "dev": "vite --host 0.0.0.0", "build": "vite build", + "check:contracts": "tsx scripts/validate-admin-architecture.ts", + "gen:api": "node scripts/sync-openapi-from-backend.mjs && openapi-typescript contracts/admin-openapi.json -o src/shared/api/generated/schema.d.ts && node scripts/generate-api-client.mjs", + "gen:module": "node scripts/generate-admin-module.mjs", "lint": "eslint .", "format": "prettier --write .", + "scaffold:feature": "node scripts/scaffold-feature.mjs", + "sync:contract": "node scripts/sync-openapi-from-backend.mjs", "test": "vitest run", + "typecheck": "tsc --noEmit", "preview": "vite preview --host 0.0.0.0" }, "dependencies": { @@ -17,22 +23,33 @@ "@emotion/styled": "11.14.1", "@mui/icons-material": "9.0.0", "@mui/material": "9.0.0", + "@tanstack/react-query": "^5.100.6", "echarts": "6.0.0", + "libpag": "4.5.52", "react": "19.2.5", "react-dom": "19.2.5", - "react-router-dom": "^7.14.2" + "react-router-dom": "^7.14.2", + "svgaplayerweb": "2.3.2", + "zod": "^4.4.1" }, "devDependencies": { "@eslint/js": "^10.0.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^25.6.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "6.0.1", "eslint": "^10.2.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", "jsdom": "^29.1.1", + "openapi-typescript": "^7.13.0", "prettier": "^3.8.3", + "tsx": "^4.21.0", + "typescript": "^5.9.3", "vite": "8.0.10", "vitest": "^4.1.5" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 200e7f5..22758b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,15 @@ importers: '@mui/material': specifier: 9.0.0 version: 9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/react-query': + specifier: ^5.100.6 + version: 5.100.6(react@19.2.5) echarts: specifier: 6.0.0 version: 6.0.0 + libpag: + specifier: 4.5.52 + version: 4.5.52 react: specifier: 19.2.5 version: 19.2.5 @@ -32,6 +38,12 @@ importers: react-router-dom: specifier: ^7.14.2 version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + svgaplayerweb: + specifier: 2.3.2 + version: 2.3.2 + zod: + specifier: ^4.4.1 + version: 4.4.1 devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -41,10 +53,22 @@ importers: version: 6.9.1 '@testing-library/react': specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: 6.0.1 - version: 6.0.1(vite@8.0.10) + version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)) eslint: specifier: ^10.2.1 version: 10.2.1 @@ -60,15 +84,24 @@ importers: jsdom: specifier: ^29.1.1 version: 29.1.1 + openapi-typescript: + specifier: ^7.13.0 + version: 7.13.0(typescript@5.9.3) prettier: specifier: ^3.8.3 version: 3.8.3 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 vite: specifier: 8.0.10 - version: 8.0.10 + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0) vitest: specifier: ^4.1.5 - version: 4.1.5(jsdom@29.1.1)(vite@8.0.10) + version: 4.1.5(@types/node@25.6.0)(jsdom@29.1.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)) packages: @@ -264,6 +297,162 @@ packages: '@emotion/weak-memoize@0.4.0': resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -451,6 +640,16 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.14': + resolution: {integrity: sha512-y+xFx+Zz54Xhr8jUdnLENYnt7Y7GEDL6Q03ga7rTtX8DVwefX9H+hQEPgJp1nda7vdH+wJ9/HBVvyfBuW9x6rA==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rolldown/binding-android-arm64@1.0.0-rc.17': resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -486,36 +685,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} @@ -549,6 +754,14 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tanstack/query-core@5.100.6': + resolution: {integrity: sha512-Os2CPUr98to98RYm+D4qGqGkiffn7MGSyl2547a4MljVkHE30AMJRqTiyCqBfMwzAx/I91vCkAxp5tHSla6Twg==} + + '@tanstack/react-query@5.100.6': + resolution: {integrity: sha512-uVSrps0PV16Cxmcn2rvL+dUhwTpTUtiRW347AEeYxMZXO2pZe9ja7E24PAMGoQ5u2g89DD8u4QhOviBk+RN8RA==} + peerDependencies: + react: ^18 || ^19 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -572,6 +785,12 @@ packages: '@types/react-dom': optional: true + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -593,12 +812,20 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + '@types/react-transition-group@4.4.12': resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} peerDependencies: @@ -649,6 +876,14 @@ packages: '@vitest/utils@4.1.5': resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -659,9 +894,17 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -670,6 +913,9 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -685,6 +931,9 @@ packages: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -697,6 +946,13 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + brace-expansion@5.0.5: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} @@ -706,6 +962,18 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -717,16 +985,38 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -768,6 +1058,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -785,12 +1079,23 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + echarts@6.0.0: resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.345: resolution: {integrity: sha512-F9JXQGiMrz6yVNPI2qOVPvB9HzjH5cGzhs8oJ6A28V5L/YnzN/0KsuiibqF+F1Fd9qxFzD1BUnYSd8JfULxTwg==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -798,6 +1103,10 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} @@ -805,10 +1114,22 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -869,10 +1190,22 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -895,6 +1228,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} @@ -909,6 +1246,14 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -921,6 +1266,17 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -929,6 +1285,14 @@ packages: resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==} engines: {node: '>=18'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + hasown@2.0.3: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} @@ -946,6 +1310,18 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -962,6 +1338,17 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -980,12 +1367,23 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + jsdom@29.1.1: resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} @@ -1009,6 +1407,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1024,6 +1425,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libpag@4.5.52: + resolution: {integrity: sha512-b5cWKNSKCAGv/Tm6IR8nWvAE/2PmHzTeXz+mX8rqf2AgOO5BH5PGJptSElrawTYY31fB6m0wGRDGpfgVwJLOtA==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1059,24 +1463,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1119,9 +1527,29 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -1130,6 +1558,10 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1141,6 +1573,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + node-releases@2.0.38: resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} @@ -1148,9 +1584,26 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1171,9 +1624,17 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1185,6 +1646,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -1199,6 +1663,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + postcss@8.5.12: resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} engines: {node: ^10 || ^12 || >=14} @@ -1219,10 +1687,26 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-dom@19.2.5: resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: @@ -1276,6 +1760,9 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} @@ -1286,6 +1773,13 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -1297,9 +1791,20 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1308,6 +1813,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1322,6 +1843,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -1332,10 +1857,17 @@ packages: stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svgaplayerweb@2.3.2: + resolution: {integrity: sha512-QuTvNIgy3W6Mi4h74SczEHUtAwb8m3ax7Ai7xRLUuN6hjJh49RGtWOWq1IuF2I7ECcl0HAYn8FcTn99UDz9UiQ==} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -1361,6 +1893,10 @@ packages: resolution: {integrity: sha512-JIXCerhudr/N6OWLwLF1HVsTTUo7ry6qHa5eWZEkiMuxsIiAACL55tGLfqfHfoH7QaMQUW8fngD7u7TxWexYQg==} hasBin: true + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -1375,23 +1911,55 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici@7.25.0: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} engines: {node: '>=20.18.1'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite@8.0.10: resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1506,6 +2074,9 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -1516,10 +2087,17 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + yaml@1.10.3: resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1581,7 +2159,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -1653,7 +2231,7 @@ snapshots: '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -1789,6 +2367,84 @@ snapshots: '@emotion/weak-memoize@0.4.0': {} + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1)': dependencies: eslint: 10.2.1 @@ -1799,7 +2455,7 @@ snapshots: '@eslint/config-array@0.23.5': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.5 transitivePeerDependencies: - supports-color @@ -1958,6 +2614,29 @@ snapshots: '@popperjs/core@2.11.8': {} + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.14(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.1.1 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + '@rolldown/binding-android-arm64@1.0.0-rc.17': optional: true @@ -2013,6 +2692,13 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tanstack/query-core@5.100.6': {} + + '@tanstack/react-query@5.100.6(react@19.2.5)': + dependencies: + '@tanstack/query-core': 5.100.6 + react: 19.2.5 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -2033,7 +2719,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@babel/runtime': 7.29.2 '@testing-library/dom': 10.4.1 @@ -2041,6 +2727,11 @@ snapshots: react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 '@tybys/wasm-util@0.10.1': dependencies: @@ -2062,10 +2753,18 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + '@types/parse-json@4.0.2': {} '@types/prop-types@15.7.15': {} + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + '@types/react-transition-group@4.4.12(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -2074,10 +2773,10 @@ snapshots: dependencies: csstype: 3.2.3 - '@vitejs/plugin-react@6.0.1(vite@8.0.10)': + '@vitejs/plugin-react@6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.10 + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0) '@vitest/expect@4.1.5': dependencies: @@ -2088,13 +2787,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(vite@8.0.10)': + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10 + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0) '@vitest/pretty-format@4.1.5': dependencies: @@ -2120,12 +2819,23 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 acorn@8.16.0: {} + agent-base@7.1.4: {} + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -2133,10 +2843,14 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-colors@4.1.3: {} + ansi-regex@5.0.1: {} ansi-styles@5.2.0: {} + argparse@2.0.1: {} + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -2151,6 +2865,8 @@ snapshots: cosmiconfig: 7.1.0 resolve: 1.22.12 + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} baseline-browser-mapping@2.10.24: {} @@ -2159,6 +2875,24 @@ snapshots: dependencies: require-from-string: 2.0.2 + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3(supports-color@10.2.2) + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -2171,18 +2905,42 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} caniuse-lite@1.0.30001791: {} chai@6.2.2: {} + change-case@5.4.4: {} + clsx@2.1.1: {} + colorette@1.4.0: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + convert-source-map@1.9.0: {} convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cookie@1.1.1: {} cosmiconfig@7.1.0: @@ -2215,14 +2973,18 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decimal.js@10.6.0: {} deep-is@0.1.4: {} + depd@2.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -2236,25 +2998,72 @@ snapshots: '@babel/runtime': 7.29.2 csstype: 3.2.3 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + echarts@6.0.0: dependencies: tslib: 2.3.0 zrender: 6.0.0 + ee-first@1.1.1: {} + electron-to-chromium@1.5.345: {} + encodeurl@2.0.0: {} + entities@8.0.0: {} error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 + es-define-property@1.0.1: {} + es-errors@1.3.0: {} es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-plugin-react-hooks@7.1.1(eslint@10.2.1): @@ -2297,7 +3106,7 @@ snapshots: '@types/estree': 1.0.8 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -2340,8 +3149,45 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + expect-type@1.3.0: {} + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@10.2.2) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -2356,6 +3202,17 @@ snapshots: dependencies: flat-cache: 4.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3(supports-color@10.2.2) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-root@1.1.0: {} find-up@5.0.0: @@ -2370,6 +3227,10 @@ snapshots: flatted@3.4.2: {} + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true @@ -2377,12 +3238,38 @@ snapshots: gensync@1.0.0-beta.2: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 globals@17.5.0: {} + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + hasown@2.0.3: dependencies: function-bind: 1.1.2 @@ -2403,6 +3290,25 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} import-fresh@3.3.1: @@ -2414,6 +3320,12 @@ snapshots: indent-string@4.0.0: {} + index-to-position@1.2.0: {} + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + is-arrayish@0.2.1: {} is-core-module@2.16.1: @@ -2428,10 +3340,18 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + isexe@2.0.0: {} + js-levenshtein@1.1.6: {} + js-tokens@4.0.0: {} + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + jsdom@29.1.1: dependencies: '@asamuzakjp/css-color': 5.1.11 @@ -2466,6 +3386,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -2479,6 +3401,14 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libpag@4.5.52: + dependencies: + abort-controller: 3.0.0 + cross-spawn: 7.0.6 + express: 5.2.1 + transitivePeerDependencies: + - supports-color + lightningcss-android-arm64@1.32.0: optional: true @@ -2550,26 +3480,64 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + min-indent@1.0.1: {} minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + ms@2.1.3: {} nanoid@3.3.11: {} natural-compare@1.4.0: {} + negotiator@1.0.0: {} + node-releases@2.0.38: {} object-assign@4.1.1: {} + object-inspect@1.13.4: {} + obug@2.1.1: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.14(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2598,16 +3566,26 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.0 + index-to-position: 1.2.0 + type-fest: 4.41.0 + parse5@8.0.1: dependencies: entities: 8.0.0 + parseurl@1.3.3: {} + path-exists@4.0.0: {} path-key@3.1.1: {} path-parse@1.0.7: {} + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} pathe@2.0.3: {} @@ -2616,6 +3594,8 @@ snapshots: picomatch@4.0.4: {} + pluralize@8.0.0: {} + postcss@8.5.12: dependencies: nanoid: 3.3.11 @@ -2638,8 +3618,26 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + punycode@2.3.1: {} + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + react-dom@19.2.5(react@19.2.5): dependencies: react: 19.2.5 @@ -2685,6 +3683,8 @@ snapshots: resolve-from@4.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -2713,6 +3713,18 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@10.2.2) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -2721,14 +3733,69 @@ snapshots: semver@6.3.1: {} + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@10.2.2) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-cookie-parser@2.7.2: {} + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -2737,6 +3804,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} strip-indent@3.0.0: @@ -2745,8 +3814,12 @@ snapshots: stylis@4.2.0: {} + supports-color@10.2.2: {} + supports-preserve-symlinks-flag@1.0.0: {} + svgaplayerweb@2.3.2: {} + symbol-tree@3.2.4: {} tinybench@2.9.0: {} @@ -2766,6 +3839,8 @@ snapshots: dependencies: tldts-core: 7.0.29 + toidentifier@1.0.1: {} + tough-cookie@6.0.1: dependencies: tldts: 7.0.29 @@ -2779,23 +3854,48 @@ snapshots: tslib@2.8.1: optional: true + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 + type-fest@4.41.0: {} + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + undici-types@7.19.2: {} + undici@7.25.0: {} + unpipe@1.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 escalade: 3.2.0 picocolors: 1.1.1 + uri-js-replace@1.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 - vite@8.0.10: + vary@1.1.2: {} + + vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -2803,12 +3903,15 @@ snapshots: rolldown: 1.0.0-rc.17 tinyglobby: 0.2.16 optionalDependencies: + '@types/node': 25.6.0 + esbuild: 0.27.7 fsevents: 2.3.3 + tsx: 4.21.0 - vitest@4.1.5(jsdom@29.1.1)(vite@8.0.10): + vitest@4.1.5(@types/node@25.6.0)(jsdom@29.1.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -2825,9 +3928,10 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10 + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 25.6.0 jsdom: 29.1.1 transitivePeerDependencies: - msw @@ -2859,14 +3963,20 @@ snapshots: word-wrap@1.2.5: {} + wrappy@1.0.2: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} yallist@3.1.1: {} + yaml-ast-parser@0.0.43: {} + yaml@1.10.3: {} + yargs-parser@21.1.1: {} + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@4.4.1): diff --git a/public/vendor/libpag.wasm b/public/vendor/libpag.wasm new file mode 100755 index 0000000..576589b Binary files /dev/null and b/public/vendor/libpag.wasm differ diff --git a/scripts/generate-admin-module.mjs b/scripts/generate-admin-module.mjs new file mode 100644 index 0000000..21a7dc8 --- /dev/null +++ b/scripts/generate-admin-module.mjs @@ -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 [--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; + +${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 { + 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> { + return apiRequest>(apiEndpointPath(${operationKey}), { query }); +}`; + } + + if (route.hasId) { + typeImports.add("EntityId"); + } + + if (route.method === "DELETE") { + return `export function ${route.operationId}(id: EntityId): Promise { +${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" : "payload: Record"; + const pathParams = route.hasId ? ", { id }" : ""; + return `export function ${route.operationId}(${params}): Promise<${config.singularName}Record> { +${endpointLine} + return apiRequest<${config.singularName}Record, Record>(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; +`; +} + +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 ( + +
+ + ); +} +`; +} + +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)]) + ); +} diff --git a/scripts/generate-api-client.mjs b/scripts/generate-api-client.mjs new file mode 100644 index 0000000..8f2883f --- /dev/null +++ b/scripts/generate-api-client.mjs @@ -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 = { +${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; + +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)}`); diff --git a/scripts/scaffold-feature.mjs b/scripts/scaffold-feature.mjs new file mode 100644 index 0000000..784714a --- /dev/null +++ b/scripts/scaffold-feature.mjs @@ -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 --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) { + 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 ( + +
+ + ); +} +` + ], + [ + `${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); +} diff --git a/scripts/sync-openapi-from-backend.mjs b/scripts/sync-openapi-from-backend.mjs new file mode 100644 index 0000000..a1f418c --- /dev/null +++ b/scripts/sync-openapi-from-backend.mjs @@ -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)]) + ); +} diff --git a/scripts/validate-admin-architecture.ts b/scripts/validate-admin-architecture.ts new file mode 100644 index 0000000..349ade1 --- /dev/null +++ b/scripts/validate-admin-architecture.ts @@ -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>; +}; + +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(PERMISSION_CODES); +const knownMenuCodes = new Set(MENU_CODE_VALUES); +const routesByPath = new Map(); +const routesByMenuCode = new Map(); +const routePermissions = new Set(); + +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(); +const apiPermissions = new Set(); +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 || [])]); +} diff --git a/src/App.jsx b/src/App.jsx deleted file mode 100644 index 0c39e36..0000000 --- a/src/App.jsx +++ /dev/null @@ -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 ( - - } /> - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - } /> - - ); -} - -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 ; - } - - return ; -} - -function RequireAuth() { - const { loading, user } = useAuth(); - const location = useLocation(); - - if (loading) { - return ; - } - - if (!user) { - return ; - } - - return ; -} - -function RequirePermission({ children, code }) { - const { can } = useAuth(); - - if (!can(code)) { - return ; - } - - 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 ( -
-
setRefreshTick((current) => current + 1)} - onToggleSidebar={() => setSidebarCollapsed((current) => !current)} - /> - -
- - -
-
- -
-
-
-
- ); -} - -export default App; diff --git a/src/App.test.jsx b/src/App.test.jsx index 5a536dc..63c19e5 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -1,27 +1,45 @@ import { render, screen } from "@testing-library/react"; -import { expect, test } from "vitest"; -import { ThemeProvider } from "@mui/material/styles"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; -import { AuthProvider } from "./auth/AuthProvider.jsx"; -import { ConfirmProvider } from "./components/base/ConfirmProvider.jsx"; -import { ToastProvider } from "./components/base/ToastProvider.jsx"; -import { theme } from "./theme.js"; -import App from "./App.jsx"; +import App from "@/app/App.jsx"; +import { AppProviders } from "@/app/providers.jsx"; +import { adminRoutes } from "@/app/router/routeConfig"; + +beforeEach(() => { + window.localStorage.clear(); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ code: 401, message: "unauthorized" }), { status: 401 })) + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); test("renders login route", async () => { - render( - - - - - - - - - - - - ); + renderWithRoute("/login"); expect(await screen.findByText("HYApp 管理平台")).toBeInTheDocument(); }); + +test("redirects protected route to login when unauthenticated", async () => { + renderWithRoute("/system/users"); + + expect(await screen.findByText("HYApp 管理平台")).toBeInTheDocument(); +}); + +test("admin routes declare menu code and permission", () => { + expect(adminRoutes.length).toBeGreaterThan(0); + expect(adminRoutes.every((route) => route.menuCode && route.permission)).toBe(true); +}); + +function renderWithRoute(route) { + return render( + + + + + + ); +} diff --git a/src/api/auth.js b/src/api/auth.js deleted file mode 100644 index 95c3037..0000000 --- a/src/api/auth.js +++ /dev/null @@ -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 }); -} diff --git a/src/api/dashboard.js b/src/api/dashboard.js deleted file mode 100644 index be78e80..0000000 --- a/src/api/dashboard.js +++ /dev/null @@ -1,5 +0,0 @@ -import { apiRequest } from "./request.js"; - -export function getDashboardOverview() { - return apiRequest("/v1/dashboard/overview"); -} diff --git a/src/api/logs.js b/src/api/logs.js deleted file mode 100644 index 1fbaffd..0000000 --- a/src/api/logs.js +++ /dev/null @@ -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 }); -} diff --git a/src/api/navigation.js b/src/api/navigation.js deleted file mode 100644 index c16c885..0000000 --- a/src/api/navigation.js +++ /dev/null @@ -1,5 +0,0 @@ -import { apiRequest } from "./request.js"; - -export function getMenus() { - return apiRequest("/v1/navigation/menus"); -} diff --git a/src/api/notifications.js b/src/api/notifications.js deleted file mode 100644 index 12d9e86..0000000 --- a/src/api/notifications.js +++ /dev/null @@ -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: {} }); -} diff --git a/src/api/request.js b/src/api/request.js deleted file mode 100644 index 3bc1a7b..0000000 --- a/src/api/request.js +++ /dev/null @@ -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 }; - } -} diff --git a/src/api/roles.js b/src/api/roles.js deleted file mode 100644 index c0178b4..0000000 --- a/src/api/roles.js +++ /dev/null @@ -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"); -} diff --git a/src/api/search.js b/src/api/search.js deleted file mode 100644 index f1dd1cc..0000000 --- a/src/api/search.js +++ /dev/null @@ -1,5 +0,0 @@ -import { apiRequest } from "./request.js"; - -export function searchGlobal(keyword) { - return apiRequest("/v1/search", { query: { keyword } }); -} diff --git a/src/api/services.js b/src/api/services.js deleted file mode 100644 index 8f3675f..0000000 --- a/src/api/services.js +++ /dev/null @@ -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: {} }); -} diff --git a/src/api/users.js b/src/api/users.js deleted file mode 100644 index 17309cf..0000000 --- a/src/api/users.js +++ /dev/null @@ -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 }); -} diff --git a/src/app/App.jsx b/src/app/App.jsx new file mode 100644 index 0000000..32dc0e4 --- /dev/null +++ b/src/app/App.jsx @@ -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 ( + + {publicRoutes.map((route) => ( + } /> + ))} + }> + }> + } /> + {adminRoutes.map((route) => ( + + + + } + /> + ))} + + + } /> + + ); +} + +export default App; diff --git a/src/app/ErrorBoundary.jsx b/src/app/ErrorBoundary.jsx new file mode 100644 index 0000000..733e02b --- /dev/null +++ b/src/app/ErrorBoundary.jsx @@ -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 ( +
+
+

页面加载失败

+

{this.state.error.message || "请刷新后重试"}

+
+ + +
+
+
+ ); + } +} diff --git a/src/app/ErrorBoundary.module.css b/src/app/ErrorBoundary.module.css new file mode 100644 index 0000000..e27c1b2 --- /dev/null +++ b/src/app/ErrorBoundary.module.css @@ -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); +} diff --git a/src/app/app-scope/AppScopeProvider.jsx b/src/app/app-scope/AppScopeProvider.jsx new file mode 100644 index 0000000..0aac587 --- /dev/null +++ b/src/app/app-scope/AppScopeProvider.jsx @@ -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 {children}; +} + +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(); +} diff --git a/src/auth/AuthProvider.jsx b/src/app/auth/AuthProvider.jsx similarity index 78% rename from src/auth/AuthProvider.jsx rename to src/app/auth/AuthProvider.jsx index 8ef3a71..120bde9 100644 --- a/src/auth/AuthProvider.jsx +++ b/src/app/auth/AuthProvider.jsx @@ -1,6 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; -import * as authApi from "../api/auth.js"; -import { getAccessToken, setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "../api/request.js"; +import * as authApi from "@/features/auth/api"; +import { getAccessToken, setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/shared/api/request"; const AuthContext = createContext(null); @@ -49,9 +49,22 @@ export function AuthProvider({ children }) { async function bootstrap() { try { if (getAccessToken()) { - const session = await authApi.getMe(); - if (mounted) { - applySession(session); + try { + const session = await authApi.refreshSession(); + if (mounted) { + applySession(session); + } + return; + } catch { + // Keep the existing access token as a fallback when the refresh cookie is unavailable. + } + try { + const session = await authApi.getMe(); + if (mounted) { + applySession(session); + } + } catch { + clearSession(); } return; } @@ -67,7 +80,7 @@ export function AuthProvider({ children }) { return () => { mounted = false; }; - }, [applySession, refresh]); + }, [applySession, clearSession, refresh]); const login = useCallback( async (payload) => { diff --git a/src/app/layout/AdminLayout.jsx b/src/app/layout/AdminLayout.jsx new file mode 100644 index 0000000..b055c55 --- /dev/null +++ b/src/app/layout/AdminLayout.jsx @@ -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 ( +
+
setSidebarCollapsed((current) => !current)} + /> + +
+ + +
+
+ +
+
+
+
+ ); +} diff --git a/src/components/layout/Header.jsx b/src/app/layout/Header.jsx similarity index 66% rename from src/components/layout/Header.jsx rename to src/app/layout/Header.jsx index 3b773f1..7b747d5 100644 --- a/src/components/layout/Header.jsx +++ b/src/app/layout/Header.jsx @@ -9,25 +9,37 @@ import Refresh from "@mui/icons-material/Refresh"; import Search from "@mui/icons-material/Search"; import MuiMenu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; import { lazy, Suspense, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { listNotifications } from "../../api/notifications.js"; -import { useAuth } from "../../auth/AuthProvider.jsx"; -import { IconButton } from "../base/IconButton.jsx"; -import { useToast } from "../base/ToastProvider.jsx"; +import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx"; +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { PERMISSIONS } from "@/app/permissions"; +import { listNotifications } from "@/features/notifications/api"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { IconButton } from "@/shared/ui/IconButton.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; -const SearchDialog = lazy(() => import("../search/SearchDialog.jsx").then((module) => ({ default: module.SearchDialog }))); +const SearchDialog = lazy(() => import("@/features/search/components/SearchDialog.jsx").then((module) => ({ default: module.SearchDialog }))); export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSidebar }) { const [searchOpen, setSearchOpen] = useState(false); const [searchMounted, setSearchMounted] = useState(false); const [userMenuAnchor, setUserMenuAnchor] = useState(null); - const [unread, setUnread] = useState(0); const navigate = useNavigate(); - const { logout, user } = useAuth(); + const { can, logout, user } = useAuth(); + const appScope = useAppScope(); const { showToast } = useToast(); const ToggleIcon = isSidebarCollapsed ? MenuOpen : Menu; const userMenuOpen = Boolean(userMenuAnchor); + const canViewNotifications = can(PERMISSIONS.notificationView); + const { data: notificationState } = useAdminQuery(() => listNotifications(), { + enabled: canViewNotifications, + initialData: { items: [], unread: 0 }, + queryKey: ["notifications", "unread"], + refetchInterval: 30000 + }); + const unread = notificationState?.unread || 0; useEffect(() => { if (searchOpen) { @@ -64,30 +76,6 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid navigate("/notifications"); }; - useEffect(() => { - let mounted = true; - - async function loadUnread() { - try { - const data = await listNotifications(); - if (mounted) { - setUnread(data.unread || 0); - } - } catch { - if (mounted) { - setUnread(0); - } - } - } - - loadUnread(); - const timer = window.setInterval(loadUnread, 30000); - return () => { - mounted = false; - window.clearInterval(timer); - }; - }, []); - const refresh = () => { onRefresh(); showToast("已刷新当前页面", "success"); @@ -114,16 +102,36 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
+ appScope.setAppCode(event.target.value)} + > + {appScope.apps.length ? ( + appScope.apps.map((app) => ( + + {app.appName || app.appCode} + + )) + ) : ( + {appScope.appCode} + )} + - - - + {canViewNotifications ? ( + + + + ) : null} - ))} -
- - - - ); -} - -function PanelHead({ service }) { - return ( -
- -
- ); -} - -function PanelTitle({ service }) { - return ( -
-

{service.name}

- -
- ); -} - -function StaticTabs() { - return ( -
- {detailTabs.map((tab, index) => ( - - ))} -
- ); -} - -function DetailBody({ activeTab, service }) { - return ( -
- {activeTab === "日志" ? ( -
    - {service.logs.map((log) => ( -
  • {log}
  • - ))} -
- ) : ( -
- - - - - - - - -
- )} -
- ); -} - -function DetailRow({ label, value }) { - return ( -
- {label} - {value} -
- ); -} - -function DrawerActions({ canOperate, restartService, service, setServiceStatus }) { - return ( -
- - - -
- ); -} diff --git a/src/components/service/ResourceCell.jsx b/src/components/service/ResourceCell.jsx deleted file mode 100644 index 96fa73b..0000000 --- a/src/components/service/ResourceCell.jsx +++ /dev/null @@ -1,12 +0,0 @@ -export function ResourceCell({ memory = false, value }) { - return ( -
-
- {value}% -
-
- -
-
- ); -} diff --git a/src/components/service/RowActions.jsx b/src/components/service/RowActions.jsx deleted file mode 100644 index be609ba..0000000 --- a/src/components/service/RowActions.jsx +++ /dev/null @@ -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 ( -
event.stopPropagation()}> - setServiceStatus(service.id, "running")} - label="启动" - tone="success" - > - - - restartService(service.id)} - label="重启" - > - - - setServiceStatus(service.id, "stopped")} - label="停止" - tone="danger" - > - - - - - -
- ); -} diff --git a/src/components/service/ServerRail.jsx b/src/components/service/ServerRail.jsx deleted file mode 100644 index 2d21a51..0000000 --- a/src/components/service/ServerRail.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Card } from "../base/Card.jsx"; -import { StatusBadge } from "./StatusBadge.jsx"; - -export function ServerRail({ openDetail, services }) { - return ( - -
-
服务器列表
- -
-
- {services.slice(0, 4).map((service) => ( - - ))} -
-
- ); -} diff --git a/src/components/service/ServiceTable.jsx b/src/components/service/ServiceTable.jsx deleted file mode 100644 index c2655a2..0000000 --- a/src/components/service/ServiceTable.jsx +++ /dev/null @@ -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 ( - -
-
{title}
-
共 {services.length} 条
-
-
-
-
-
服务名称
-
状态
-
部署
-
CPU
-
内存
-
操作
-
- {services.length ? ( - services.map((service) => ( -
openDetail(service.id)} role="button" tabIndex={0}> -
-
- -
-
-
{service.name}
-
{service.desc}
-
-
-
- -
-
- {service.deploy} -
{service.version}
-
- - - -
- )) - ) : ( -
暂无匹配服务
- )} -
-
-
- ); -} diff --git a/src/components/service/StatusBadge.jsx b/src/components/service/StatusBadge.jsx deleted file mode 100644 index 8c92577..0000000 --- a/src/components/service/StatusBadge.jsx +++ /dev/null @@ -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 ( - } - label={service.statusLabel} - size="small" - variant="outlined" - /> - ); -} diff --git a/src/components/user/UserTable.jsx b/src/components/user/UserTable.jsx deleted file mode 100644 index 91319b7..0000000 --- a/src/components/user/UserTable.jsx +++ /dev/null @@ -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 ( - -
-
用户清单
-
共 {total} 条
-
-
-
-
-
- toggleAll(event.target.checked)} size="small" /> -
-
用户
-
角色
-
团队
-
状态
-
MFA
-
最后登录
-
操作
-
- {users.length ? ( - users.map((user) => ( -
-
- { - setSelectedIds((current) => - event.target.checked ? [...current, user.id] : current.filter((id) => id !== user.id) - ); - }} - size="small" - /> -
-
-
- -
-
-
{user.name}
-
{user.account}
-
-
-
{user.role || "-"}
-
{user.team || "-"}
-
- -
-
{user.mfa}
-
{user.lastLogin}
- -
- )) - ) : ( -
暂无匹配用户
- )} -
-
-
- ); -} - -function UserRowActions({ - canEdit, - canResetPassword, - canStatus, - onEdit, - onResetPassword, - onToggleDisabled, - onToggleLocked, - user -}) { - const isDisabled = user.status === "disabled"; - const isLocked = user.status === "locked"; - - return ( -
- onEdit(user)}> - - - onResetPassword(user)}> - - - onToggleLocked(user)} - tone={isLocked ? "success" : undefined} - > - {isLocked ? : } - - onToggleDisabled(user)} - tone={isDisabled ? "success" : "danger"} - > - {isDisabled ? : } - -
- ); -} diff --git a/src/constants/navigation.js b/src/constants/navigation.js deleted file mode 100644 index f72c2b5..0000000 --- a/src/constants/navigation.js +++ /dev/null @@ -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; -} diff --git a/src/constants/status.js b/src/constants/status.js deleted file mode 100644 index 3893948..0000000 --- a/src/constants/status.js +++ /dev/null @@ -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 = ["基础信息", "配置", "监控", "日志"]; diff --git a/src/data/services.js b/src/data/services.js deleted file mode 100644 index 73b6e24..0000000 --- a/src/data/services.js +++ /dev/null @@ -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]; diff --git a/src/features/app-config/api.ts b/src/features/app-config/api.ts new file mode 100644 index 0000000..46d235f --- /dev/null +++ b/src/features/app-config/api.ts @@ -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> { + const endpoint = API_ENDPOINTS.listH5Links; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listH5Links), { + method: endpoint.method + }); +} + +export function updateH5Links(payload: H5LinkConfigUpdatePayload): Promise> { + const endpoint = API_ENDPOINTS.updateH5Links; + return apiRequest, H5LinkConfigUpdatePayload>(apiEndpointPath(API_OPERATIONS.updateH5Links), { + body: payload, + method: endpoint.method + }); +} diff --git a/src/features/app-config/app-config.module.css b/src/features/app-config/app-config.module.css new file mode 100644 index 0000000..4cf26cb --- /dev/null +++ b/src/features/app-config/app-config.module.css @@ -0,0 +1,7 @@ +.linkText { + overflow: hidden; + max-width: 100%; + color: var(--text-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/features/app-config/constants.ts b/src/features/app-config/constants.ts new file mode 100644 index 0000000..8fc3a99 --- /dev/null +++ b/src/features/app-config/constants.ts @@ -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]; diff --git a/src/features/app-config/hooks/useH5ConfigPage.js b/src/features/app-config/hooks/useH5ConfigPage.js new file mode 100644 index 0000000..e9fe7f7 --- /dev/null +++ b/src/features/app-config/hooks/useH5ConfigPage.js @@ -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 + }; +} diff --git a/src/features/app-config/pages/H5ConfigPage.jsx b/src/features/app-config/pages/H5ConfigPage.jsx new file mode 100644 index 0000000..311ccb1 --- /dev/null +++ b/src/features/app-config/pages/H5ConfigPage.jsx @@ -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) => {item.url || "-"}, + 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) => , + width: "minmax(88px, 0.4fr)" + } + ] + : baseColumns; + + return ( + + + + item.key} /> +
+ {items.length} 条 +
+
+
+ + + page.setForm({ url: event.target.value })} + /> + +
+ ); +} + +function H5LinkActions({ item, page }) { + return ( + + page.openEdit(item)}> + {item.url ? : } + + + ); +} diff --git a/src/features/app-config/permissions.js b/src/features/app-config/permissions.js new file mode 100644 index 0000000..7f31115 --- /dev/null +++ b/src/features/app-config/permissions.js @@ -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) + }; +} diff --git a/src/features/app-config/routes.js b/src/features/app-config/routes.js new file mode 100644 index 0000000..7afc396 --- /dev/null +++ b/src/features/app-config/routes.js @@ -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 + } +]; diff --git a/src/features/app-config/schema.ts b/src/features/app-config/schema.ts new file mode 100644 index 0000000..bad434f --- /dev/null +++ b/src/features/app-config/schema.ts @@ -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; diff --git a/src/features/app-registry/api.ts b/src/features/app-registry/api.ts new file mode 100644 index 0000000..c55cf62 --- /dev/null +++ b/src/features/app-registry/api.ts @@ -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> { + const endpoint = API_ENDPOINTS.listApps; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listApps), { + method: endpoint.method + }); +} diff --git a/src/features/app-users/api.ts b/src/features/app-users/api.ts new file mode 100644 index 0000000..b30ad04 --- /dev/null +++ b/src/features/app-users/api.ts @@ -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> { + const endpoint = API_ENDPOINTS.appListUsers; + return apiRequest>(apiEndpointPath(API_OPERATIONS.appListUsers), { + method: endpoint.method, + query + }); +} + +export function updateAppUser(userId: EntityId, payload: AppUserUpdatePayload): Promise { + const endpoint = API_ENDPOINTS.appUpdateUser; + return apiRequest(apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }), { + body: payload, + method: endpoint.method + }); +} + +export function banAppUser(userId: EntityId): Promise { + const endpoint = API_ENDPOINTS.appBanUser; + return apiRequest(apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }), { + method: endpoint.method + }); +} + +export function unbanAppUser(userId: EntityId): Promise { + const endpoint = API_ENDPOINTS.appUnbanUser; + return apiRequest(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 + } + ); +} diff --git a/src/features/app-users/app-users.module.css b/src/features/app-users/app-users.module.css new file mode 100644 index 0000000..a10d49e --- /dev/null +++ b/src/features/app-users/app-users.module.css @@ -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%; + } +} diff --git a/src/features/app-users/constants.js b/src/features/app-users/constants.js new file mode 100644 index 0000000..57945ca --- /dev/null +++ b/src/features/app-users/constants.js @@ -0,0 +1,18 @@ +export const appUserStatusFilters = [ + ["", "全部"], + ["active", "正常"], + ["banned", "封禁"] +]; + +export const appUserStatusLabels = { + active: "正常", + banned: "封禁", + disabled: "封禁" +}; + +export const genderOptions = [ + ["", "未设置"], + ["male", "男"], + ["female", "女"], + ["unknown", "未知"] +]; diff --git a/src/features/app-users/hooks/useAppUsersPage.js b/src/features/app-users/hooks/useAppUsersPage.js new file mode 100644 index 0000000..f99348e --- /dev/null +++ b/src/features/app-users/hooks/useAppUsersPage.js @@ -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"; +} diff --git a/src/features/app-users/pages/AppUserListPage.jsx b/src/features/app-users/pages/AppUserListPage.jsx new file mode 100644 index 0000000..b236183 --- /dev/null +++ b/src/features/app-users/pages/AppUserListPage.jsx @@ -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) => + }, + { 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) => + }, + { + key: "time", + label: "创建 / 活跃", + width: "minmax(180px, 1fr)", + render: (user) => ( +
+ {formatMillis(user.createdAtMs)} + {formatMillis(user.lastActiveAtMs)} +
+ ) + } +]; + +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) => + }, + ...columns.slice(1), + { + key: "actions", + label: "操作", + width: "minmax(148px, 0.8fr)", + render: (user) => + } + ]; + + return ( +
+
+
+
+ + +
+
+ + +
+ user.userId} /> + {total > 0 ? : null} +
+
+
+ + + page.setEditForm({ ...page.editForm, username: event.target.value })} /> + page.setEditForm({ ...page.editForm, avatar })} /> + page.setEditForm({ ...page.editForm, gender: event.target.value })}> + {genderOptions.map(([value, label]) => ( + {label} + ))} + + + + + option.label || ""} + isOptionEqualToValue={(option, value) => option.value === value.value} + loading={page.loadingCountries} + options={page.countryOptions} + renderInput={(params) => } + size="small" + value={page.countryOptions.find((option) => option.value === page.countryForm.country) || null} + onChange={(_, option) => page.setCountryForm({ country: option?.value || "" })} + /> + + + + page.setPasswordForm({ ...page.passwordForm, password: event.target.value })} /> + +
+ ); +} + +function UserLocation({ page, user }) { + const countryLabel = formatCountry(user); + const regionLabel = user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-"); + return ( +
+ {page.abilities.canUpdate ? ( + + ) : ( + {countryLabel} + )} + {regionLabel} +
+ ); +} + +function UserIdentity({ user }) { + const name = user.username || "-"; + const shortId = user.displayUserId || user.userId; + const gender = genderView(user.gender); + return ( +
+ + {user.avatar ? : } + +
+
+ {name} + {gender.symbol} +
+
{shortId}
+
+
+ ); +} + +function UserActions({ page, user }) { + const banned = user.status === "banned" || user.status === "disabled"; + return ( +
+ {page.abilities.canUpdate ? ( + + + page.openEdit(user)}> + + + + + ) : null} + {page.abilities.canStatus ? ( + + + page.toggleBan(user)}> + {banned ? : } + + + + ) : null} + {page.abilities.canPassword ? ( + + + page.openPassword(user)}> + + + + + ) : null} +
+ ); +} + +function UserStatus({ status }) { + const tone = status === "active" ? "running" : "danger"; + return ( + + + {appUserStatusLabels[status] || status || "-"} + + ); +} + +function ActionModal({ children, disabled, loading, onClose, onSubmit, open, title }) { + return ( + + {children} + + ); +} + +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 || "-"; +} diff --git a/src/features/app-users/permissions.js b/src/features/app-users/permissions.js new file mode 100644 index 0000000..eea5498 --- /dev/null +++ b/src/features/app-users/permissions.js @@ -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) + }; +} diff --git a/src/features/app-users/routes.js b/src/features/app-users/routes.js new file mode 100644 index 0000000..1c5d27b --- /dev/null +++ b/src/features/app-users/routes.js @@ -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 + } +]; diff --git a/src/features/app-users/schema.ts b/src/features/app-users/schema.ts new file mode 100644 index 0000000..9da6adb --- /dev/null +++ b/src/features/app-users/schema.ts @@ -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; +export type AppUserCountryForm = z.infer; +export type AppUserPasswordForm = z.infer; diff --git a/src/features/auth/api.ts b/src/features/auth/api.ts new file mode 100644 index 0000000..3135d07 --- /dev/null +++ b/src/features/auth/api.ts @@ -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 { + const endpoint = API_ENDPOINTS.login; + return apiRequest(apiEndpointPath(API_OPERATIONS.login), { + body: payload, + method: endpoint.method, + skipAuth: true + }); +} + +export function logout(): Promise { + const endpoint = API_ENDPOINTS.logout; + return apiRequest(apiEndpointPath(API_OPERATIONS.logout), { method: endpoint.method, skipAuth: true }); +} + +export function refreshSession(): Promise { + const endpoint = API_ENDPOINTS.refresh; + return apiRequest(apiEndpointPath(API_OPERATIONS.refresh), { + method: endpoint.method, + skipAuth: true + }); +} + +export function getMe(): Promise { + return apiRequest(apiEndpointPath(API_OPERATIONS.me)); +} + +export function changePassword(payload: ChangePasswordPayload): Promise { + const endpoint = API_ENDPOINTS.changePassword; + return apiRequest(apiEndpointPath(API_OPERATIONS.changePassword), { + body: payload, + method: endpoint.method + }); +} diff --git a/src/pages/LoginPage.jsx b/src/features/auth/pages/LoginPage.jsx similarity index 92% rename from src/pages/LoginPage.jsx rename to src/features/auth/pages/LoginPage.jsx index c481437..ea81fdd 100644 --- a/src/pages/LoginPage.jsx +++ b/src/features/auth/pages/LoginPage.jsx @@ -3,9 +3,9 @@ import LoginOutlined from "@mui/icons-material/LoginOutlined"; import TextField from "@mui/material/TextField"; import { useState } from "react"; import { Navigate, useLocation, useNavigate } from "react-router-dom"; -import { useAuth } from "../auth/AuthProvider.jsx"; -import { Button } from "../components/base/Button.jsx"; -import { useToast } from "../components/base/ToastProvider.jsx"; +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { Button } from "@/shared/ui/Button.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; export function LoginPage() { const [username, setUsername] = useState("admin"); diff --git a/src/features/auth/routes.js b/src/features/auth/routes.js new file mode 100644 index 0000000..239cabe --- /dev/null +++ b/src/features/auth/routes.js @@ -0,0 +1,8 @@ +export const authRoutes = [ + { + label: "登录", + loader: () => import("./pages/LoginPage.jsx").then((module) => module.LoginPage), + pageKey: "login", + path: "/login" + } +]; diff --git a/src/features/dashboard/api.ts b/src/features/dashboard/api.ts new file mode 100644 index 0000000..c6a28f6 --- /dev/null +++ b/src/features/dashboard/api.ts @@ -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 { + return apiRequest(apiEndpointPath(API_OPERATIONS.dashboardOverview)); +} diff --git a/src/features/dashboard/components/DashboardAlerts.jsx b/src/features/dashboard/components/DashboardAlerts.jsx new file mode 100644 index 0000000..6c14b4e --- /dev/null +++ b/src/features/dashboard/components/DashboardAlerts.jsx @@ -0,0 +1,16 @@ +export function DashboardAlerts({ alerts = [] }) { + return ( +
+ {alerts.map((alert) => ( +
+ +
+
{alert.name}
+
{alert.desc}
+
+
{alert.time}
+
+ ))} +
+ ); +} diff --git a/src/features/dashboard/components/DashboardCharts.jsx b/src/features/dashboard/components/DashboardCharts.jsx new file mode 100644 index 0000000..2d72a0f --- /dev/null +++ b/src/features/dashboard/components/DashboardCharts.jsx @@ -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 ( +
+ + + +
+
通知趋势
+
+ +
+
+ ); +} diff --git a/src/features/dashboard/components/DashboardStats.jsx b/src/features/dashboard/components/DashboardStats.jsx new file mode 100644 index 0000000..816c967 --- /dev/null +++ b/src/features/dashboard/components/DashboardStats.jsx @@ -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 ( +
+ 启用 {stats.activeUsers}} /> + + + 锁定 {stats.lockedUsers} / 停用 {stats.disabledUsers}} /> +
+ ); +} diff --git a/src/features/dashboard/components/DashboardToolbar.jsx b/src/features/dashboard/components/DashboardToolbar.jsx new file mode 100644 index 0000000..f617923 --- /dev/null +++ b/src/features/dashboard/components/DashboardToolbar.jsx @@ -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 ( + + + + + ); +} diff --git a/src/features/dashboard/dashboard.css b/src/features/dashboard/dashboard.css new file mode 100644 index 0000000..fa10cb8 --- /dev/null +++ b/src/features/dashboard/dashboard.css @@ -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); +} diff --git a/src/features/dashboard/hooks/useDashboardPage.js b/src/features/dashboard/hooks/useDashboardPage.js new file mode 100644 index 0000000..2ce6aa7 --- /dev/null +++ b/src/features/dashboard/hooks/useDashboardPage.js @@ -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 + }; +} diff --git a/src/features/dashboard/pages/OverviewPage.jsx b/src/features/dashboard/pages/OverviewPage.jsx new file mode 100644 index 0000000..0ace248 --- /dev/null +++ b/src/features/dashboard/pages/OverviewPage.jsx @@ -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 ( + <> + + + + + + + + + + ); +} diff --git a/src/features/dashboard/permissions.js b/src/features/dashboard/permissions.js new file mode 100644 index 0000000..e4f271a --- /dev/null +++ b/src/features/dashboard/permissions.js @@ -0,0 +1,3 @@ +export function useDashboardAbilities() { + return {}; +} diff --git a/src/features/dashboard/routes.js b/src/features/dashboard/routes.js new file mode 100644 index 0000000..e27635f --- /dev/null +++ b/src/features/dashboard/routes.js @@ -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 + } +]; diff --git a/src/features/host-org/api.test.ts b/src/features/host-org/api.test.ts new file mode 100644 index 0000000..2c9c22d --- /dev/null +++ b/src/features/host-org/api.test.ts @@ -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"); +}); diff --git a/src/features/host-org/api.ts b/src/features/host-org/api.ts new file mode 100644 index 0000000..abf24c7 --- /dev/null +++ b/src/features/host-org/api.ts @@ -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> { + const endpoint = API_ENDPOINTS.listCountries; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listCountries), { + method: endpoint.method, + query + }); +} + +export function getCountry(countryId: EntityId): Promise { + const endpoint = API_ENDPOINTS.getCountry; + return apiRequest(apiEndpointPath(API_OPERATIONS.getCountry, { country_id: countryId }), { + method: endpoint.method + }); +} + +export function createCountry(payload: CountryPayload): Promise { + const endpoint = API_ENDPOINTS.createCountry; + return apiRequest(apiEndpointPath(API_OPERATIONS.createCountry), { + body: payload, + method: endpoint.method + }); +} + +export function updateCountry(countryId: EntityId, payload: CountryUpdatePayload): Promise { + const endpoint = API_ENDPOINTS.updateCountry; + return apiRequest(apiEndpointPath(API_OPERATIONS.updateCountry, { country_id: countryId }), { + body: payload, + method: endpoint.method + }); +} + +export function enableCountry(countryId: EntityId): Promise { + const endpoint = API_ENDPOINTS.enableCountry; + return apiRequest(apiEndpointPath(API_OPERATIONS.enableCountry, { country_id: countryId }), { + method: endpoint.method + }); +} + +export function disableCountry(countryId: EntityId): Promise { + const endpoint = API_ENDPOINTS.disableCountry; + return apiRequest(apiEndpointPath(API_OPERATIONS.disableCountry, { country_id: countryId }), { + method: endpoint.method + }); +} + +export function deleteCountry(countryId: EntityId): Promise { + const endpoint = API_ENDPOINTS.deleteCountry; + return apiRequest(apiEndpointPath(API_OPERATIONS.deleteCountry, { country_id: countryId }), { + method: endpoint.method + }); +} + +export function getRegion(regionId: EntityId): Promise { + const endpoint = API_ENDPOINTS.getRegion; + return apiRequest(apiEndpointPath(API_OPERATIONS.getRegion, { region_id: regionId }), { + method: endpoint.method + }); +} + +export function createRegion(payload: RegionPayload): Promise { + const endpoint = API_ENDPOINTS.createRegion; + return apiRequest(apiEndpointPath(API_OPERATIONS.createRegion), { + body: payload, + method: endpoint.method + }); +} + +export function updateRegion(regionId: EntityId, payload: RegionUpdatePayload): Promise { + const endpoint = API_ENDPOINTS.updateRegion; + return apiRequest(apiEndpointPath(API_OPERATIONS.updateRegion, { region_id: regionId }), { + body: payload, + method: endpoint.method + }); +} + +export function replaceRegionCountries(regionId: EntityId, payload: RegionCountriesPayload): Promise { + const endpoint = API_ENDPOINTS.replaceRegionCountries; + return apiRequest( + apiEndpointPath(API_OPERATIONS.replaceRegionCountries, { region_id: regionId }), + { + body: payload, + method: endpoint.method + } + ); +} + +export function enableRegion(regionId: EntityId): Promise { + const endpoint = API_ENDPOINTS.enableRegion; + return apiRequest(apiEndpointPath(API_OPERATIONS.enableRegion, { region_id: regionId }), { + method: endpoint.method + }); +} + +export function disableRegion(regionId: EntityId): Promise { + const endpoint = API_ENDPOINTS.disableRegion; + return apiRequest(apiEndpointPath(API_OPERATIONS.disableRegion, { region_id: regionId }), { + method: endpoint.method + }); +} + +export function listBDLeaders(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listBDLeaders; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listBDLeaders), { + method: endpoint.method, + query + }); +} + +export function listBDs(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listBDs; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listBDs), { + method: endpoint.method, + query + }); +} + +export function listAgencies(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listAgencies; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listAgencies), { + method: endpoint.method, + query + }); +} + +export function listHosts(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listHosts; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listHosts), { + method: endpoint.method, + query + }); +} + +export function listCoinSellers(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listCoinSellers; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listCoinSellers), { + method: endpoint.method, + query + }); +} + +export function createBDLeader(payload: CreateBDLeaderPayload): Promise { + const endpoint = API_ENDPOINTS.createBDLeader; + return apiRequest(apiEndpointPath(API_OPERATIONS.createBDLeader), { + body: payload, + method: endpoint.method + }); +} + +export function createBD(payload: CreateBDPayload): Promise { + const endpoint = API_ENDPOINTS.createBD; + return apiRequest(apiEndpointPath(API_OPERATIONS.createBD), { + body: payload, + method: endpoint.method + }); +} + +export function setBDLeaderStatus(userId: EntityId, payload: BDStatusPayload): Promise { + const endpoint = API_ENDPOINTS.setBDLeaderStatus; + return apiRequest(apiEndpointPath(API_OPERATIONS.setBDLeaderStatus, { user_id: userId }), { + body: payload, + method: endpoint.method + }); +} + +export function setBDStatus(userId: EntityId, payload: BDStatusPayload): Promise { + const endpoint = API_ENDPOINTS.setBDStatus; + return apiRequest(apiEndpointPath(API_OPERATIONS.setBDStatus, { user_id: userId }), { + body: payload, + method: endpoint.method + }); +} + +export function createCoinSeller(payload: CreateCoinSellerPayload): Promise { + const endpoint = API_ENDPOINTS.createCoinSeller; + return apiRequest(apiEndpointPath(API_OPERATIONS.createCoinSeller), { + body: payload, + method: endpoint.method + }); +} + +export function setCoinSellerStatus(userId: EntityId, payload: CoinSellerStatusPayload): Promise { + const endpoint = API_ENDPOINTS.setCoinSellerStatus; + return apiRequest( + apiEndpointPath(API_OPERATIONS.setCoinSellerStatus, { user_id: userId }), + { + body: payload, + method: endpoint.method + } + ); +} + +export function createAgency(payload: CreateAgencyPayload): Promise { + const endpoint = API_ENDPOINTS.createAgency; + return apiRequest(apiEndpointPath(API_OPERATIONS.createAgency), { + body: payload, + method: endpoint.method + }); +} + +export function closeAgency(agencyId: EntityId, payload: HostCommandPayload): Promise { + const endpoint = API_ENDPOINTS.closeAgency; + return apiRequest(apiEndpointPath(API_OPERATIONS.closeAgency, { agency_id: agencyId }), { + body: payload, + method: endpoint.method + }); +} + +export function setAgencyJoinEnabled(agencyId: EntityId, payload: AgencyJoinEnabledPayload): Promise { + const endpoint = API_ENDPOINTS.setAgencyJoinEnabled; + return apiRequest( + apiEndpointPath(API_OPERATIONS.setAgencyJoinEnabled, { agency_id: agencyId }), + { + body: payload, + method: endpoint.method + } + ); +} diff --git a/src/features/host-org/components/HostOrgActionModal.jsx b/src/features/host-org/components/HostOrgActionModal.jsx new file mode 100644 index 0000000..6a38015 --- /dev/null +++ b/src/features/host-org/components/HostOrgActionModal.jsx @@ -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 ( + + {children} + + ); +} diff --git a/src/features/host-org/components/HostOrgFilters.jsx b/src/features/host-org/components/HostOrgFilters.jsx new file mode 100644 index 0000000..190e78b --- /dev/null +++ b/src/features/host-org/components/HostOrgFilters.jsx @@ -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 ( +
+ + +
+ + {extraFilters.map((filter) => ( + filter.onChange(event.target.value)} + /> + ))} +
+
+ ); +} diff --git a/src/features/host-org/components/HostOrgStatus.jsx b/src/features/host-org/components/HostOrgStatus.jsx new file mode 100644 index 0000000..deb4892 --- /dev/null +++ b/src/features/host-org/components/HostOrgStatus.jsx @@ -0,0 +1,23 @@ +import { statusLabels } from "@/features/host-org/constants.js"; + +export function HostOrgStatus({ value }) { + const status = value || "-"; + const tone = getStatusTone(status); + + return ( + + + {statusLabels[status] || status} + + ); +} + +function getStatusTone(status) { + if (status === "active") { + return "running"; + } + if (status === "closed" || status === "disabled") { + return "danger"; + } + return "stopped"; +} diff --git a/src/features/host-org/components/HostOrgTable.jsx b/src/features/host-org/components/HostOrgTable.jsx new file mode 100644 index 0000000..e40b2b1 --- /dev/null +++ b/src/features/host-org/components/HostOrgTable.jsx @@ -0,0 +1,5 @@ +import { DataTable } from "@/shared/ui/DataTable.jsx"; + +export function HostOrgTable(props) { + return ; +} diff --git a/src/features/host-org/components/HostOrgToolbar.jsx b/src/features/host-org/components/HostOrgToolbar.jsx new file mode 100644 index 0000000..946238e --- /dev/null +++ b/src/features/host-org/components/HostOrgToolbar.jsx @@ -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 ( +
+
+ +
+ + {actions.length ? ( +
+ {actions.map((action) => ( + + + + {action.icon} + + + + ))} +
+ ) : null} +
+ ); +} + +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)" + } +}; diff --git a/src/features/host-org/constants.js b/src/features/host-org/constants.js new file mode 100644 index 0000000..4599bde --- /dev/null +++ b/src/features/host-org/constants.js @@ -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: "停用" +}; diff --git a/src/features/host-org/hooks/useCountryOptions.js b/src/features/host-org/hooks/useCountryOptions.js new file mode 100644 index 0000000..eb12fd5 --- /dev/null +++ b/src/features/host-org/hooks/useCountryOptions.js @@ -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(" · "); +} diff --git a/src/features/host-org/hooks/useHostAgenciesPage.js b/src/features/host-org/hooks/useHostAgenciesPage.js new file mode 100644 index 0000000..1715eb6 --- /dev/null +++ b/src/features/host-org/hooks/useHostAgenciesPage.js @@ -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()}`; +} diff --git a/src/features/host-org/hooks/useHostBdsPage.js b/src/features/host-org/hooks/useHostBdsPage.js new file mode 100644 index 0000000..dfe3371 --- /dev/null +++ b/src/features/host-org/hooks/useHostBdsPage.js @@ -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()}`; +} diff --git a/src/features/host-org/hooks/useHostCoinSellersPage.js b/src/features/host-org/hooks/useHostCoinSellersPage.js new file mode 100644 index 0000000..a633ba5 --- /dev/null +++ b/src/features/host-org/hooks/useHostCoinSellersPage.js @@ -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()}`; +} diff --git a/src/features/host-org/hooks/useHostCountriesPage.js b/src/features/host-org/hooks/useHostCountriesPage.js new file mode 100644 index 0000000..9018c27 --- /dev/null +++ b/src/features/host-org/hooks/useHostCountriesPage.js @@ -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)); + }); +} diff --git a/src/features/host-org/hooks/useHostHostsPage.js b/src/features/host-org/hooks/useHostHostsPage.js new file mode 100644 index 0000000..88e8e10 --- /dev/null +++ b/src/features/host-org/hooks/useHostHostsPage.js @@ -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 + }; +} diff --git a/src/features/host-org/hooks/useHostRegionsPage.js b/src/features/host-org/hooks/useHostRegionsPage.js new file mode 100644 index 0000000..d43c577 --- /dev/null +++ b/src/features/host-org/hooks/useHostRegionsPage.js @@ -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)); + }); +} diff --git a/src/features/host-org/host-org.module.css b/src/features/host-org/host-org.module.css new file mode 100644 index 0000000..25afe30 --- /dev/null +++ b/src/features/host-org/host-org.module.css @@ -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%; + } +} diff --git a/src/features/host-org/pages/HostAgenciesPage.jsx b/src/features/host-org/pages/HostAgenciesPage.jsx new file mode 100644 index 0000000..d33f8fd --- /dev/null +++ b/src/features/host-org/pages/HostAgenciesPage.jsx @@ -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) => , width: "minmax(90px, 0.7fr)" }, + { + key: "joinEnabled", + label: "入会", + render: (item) => {item.joinEnabled ? "允许" : "禁止"}, + 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: , label: "入会开关", onClick: page.openJoinEnabledForm } + : null, + page.abilities.canStatus + ? { icon: , label: "关闭 Agency", onClick: page.openCloseForm, tone: "danger" } + : null, + page.abilities.canCreate + ? { icon: , label: "创建 Agency", onClick: page.openAgencyForm, variant: "primary" } + : null + ].filter(Boolean); + return ( +
+
+ + + +
+ item.agencyId} + /> + {total > 0 ? : null} +
+
+
+ + + page.setAgencyForm({ ...page.agencyForm, name: event.target.value })} /> + page.setAgencyForm({ ...page.agencyForm, ownerUserId: event.target.value })} /> + page.setAgencyForm({ ...page.agencyForm, parentBdUserId: event.target.value })} /> + page.setAgencyForm({ ...page.agencyForm, maxHosts: event.target.value })} /> + page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })} />} + label={page.agencyForm.joinEnabled ? "允许入会" : "禁止入会"} + /> + page.setAgencyForm({ ...page.agencyForm, reason: event.target.value })} /> + + + + page.setJoinEnabledForm({ ...page.joinEnabledForm, agencyId: event.target.value })} /> + page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked })} />} + label={page.joinEnabledForm.joinEnabled ? "允许入会" : "禁止入会"} + /> + page.setJoinEnabledForm({ ...page.joinEnabledForm, reason: event.target.value })} /> + + + + page.setCloseForm({ ...page.closeForm, agencyId: event.target.value })} /> + page.setCloseForm({ ...page.closeForm, reason: event.target.value })} /> + + +
+ ); +} diff --git a/src/features/host-org/pages/HostBdLeadersPage.jsx b/src/features/host-org/pages/HostBdLeadersPage.jsx new file mode 100644 index 0000000..c82276d --- /dev/null +++ b/src/features/host-org/pages/HostBdLeadersPage.jsx @@ -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) => , 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: , label: "BD Leader 状态", onClick: page.openBDStatusForm } + : null, + page.abilities.canCreate + ? { icon: , label: "创建 BD Leader", onClick: page.openBDLeaderForm, variant: "primary" } + : null + ].filter(Boolean); + return ( +
+
+ + + +
+ `bd-leader-${item.userId}`} + /> + {total > 0 ? : null} +
+
+
+ + + page.setBDLeaderForm({ ...page.bdLeaderForm, targetUserId: event.target.value })} /> + page.setBDLeaderForm({ ...page.bdLeaderForm, regionId: value })} /> + page.setBDLeaderForm({ ...page.bdLeaderForm, reason: event.target.value })} /> + + + + page.setBDStatusForm({ ...page.bdStatusForm, targetUserId: event.target.value })} /> + page.setBDStatusForm({ ...page.bdStatusForm, status: event.target.value })}> + 启用 + 停用 + + page.setBDStatusForm({ ...page.bdStatusForm, reason: event.target.value })} /> + + +
+ ); +} diff --git a/src/features/host-org/pages/HostBdsPage.jsx b/src/features/host-org/pages/HostBdsPage.jsx new file mode 100644 index 0000000..4b9a680 --- /dev/null +++ b/src/features/host-org/pages/HostBdsPage.jsx @@ -0,0 +1,111 @@ +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 { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js"; +import styles from "@/features/host-org/host-org.module.css"; + +const bdColumns = [ + { key: "userId", label: "用户 ID", width: "minmax(110px, 0.8fr)" }, + { key: "role", label: "角色", width: "minmax(110px, 0.8fr)" }, + { key: "parentLeaderUserId", label: "上级 Leader 用户 ID", render: (item) => item.parentLeaderUserId || "-" }, + { key: "regionId", label: "区域 ID", width: "minmax(90px, 0.7fr)" }, + { key: "status", label: "状态", render: (item) => , 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 HostBdsPage() { + const page = useHostBdsPage({ profileType: "bd" }); + 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: , label: "BD 状态", onClick: page.openBDStatusForm } + : null, + page.abilities.canCreate + ? { icon: , label: "创建 BD", onClick: page.openBDForm, variant: "primary" } + : null + ].filter(Boolean); + return ( +
+
+ + + +
+ `bd-${item.userId}`} + /> + {total > 0 ? : null} +
+
+
+ + + page.setBDForm({ ...page.bdForm, targetUserId: event.target.value })} /> + page.setBDForm({ ...page.bdForm, parentLeaderUserId: event.target.value })} /> + page.setBDForm({ ...page.bdForm, reason: event.target.value })} /> + + + + page.setBDStatusForm({ ...page.bdStatusForm, targetUserId: event.target.value })} /> + page.setBDStatusForm({ ...page.bdStatusForm, status: event.target.value })}> + 启用 + 停用 + + page.setBDStatusForm({ ...page.bdStatusForm, reason: event.target.value })} /> + + +
+ ); +} diff --git a/src/features/host-org/pages/HostCoinSellersPage.jsx b/src/features/host-org/pages/HostCoinSellersPage.jsx new file mode 100644 index 0000000..f111219 --- /dev/null +++ b/src/features/host-org/pages/HostCoinSellersPage.jsx @@ -0,0 +1,169 @@ +import Add from "@mui/icons-material/Add"; +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import MenuItem from "@mui/material/MenuItem"; +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 { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; +import { coinSellerStatusFilters } from "@/features/host-org/constants.js"; +import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx"; +import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; +import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx"; +import { useHostCoinSellersPage } from "@/features/host-org/hooks/useHostCoinSellersPage.js"; +import styles from "@/features/host-org/host-org.module.css"; + +const baseColumns = [ + { + key: "seller", + label: "币商", + render: (item) => , + width: "minmax(220px, 1.3fr)" + }, + { key: "regionId", label: "区域", render: (item, _index, context) => regionName(item, context?.regionOptions), width: "minmax(140px, 0.8fr)" }, + { key: "merchantAssetType", label: "资产类型", render: (item) => item.merchantAssetType || "-", width: "minmax(150px, 0.9fr)" }, + { key: "merchantBalance", label: "币商余额", render: (item) => formatNumber(item.merchantBalance), width: "minmax(120px, 0.8fr)" }, + { key: "createdByUserId", label: "创建人 ID", render: (item) => item.createdByUserId || "-", width: "minmax(110px, 0.7fr)" }, + { key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" } +]; + +export function HostCoinSellersPage() { + const page = useHostCoinSellersPage(); + const items = page.data.items || []; + const total = page.data.total || 0; + const createDisabled = !page.abilities.canCreate; + const updateDisabled = !page.abilities.canUpdate; + const columns = [ + ...baseColumns.slice(0, 2), + { + key: "status", + label: "状态", + render: (item) => , + width: "minmax(90px, 0.6fr)" + }, + ...baseColumns.slice(2), + page.abilities.canUpdate + ? { + key: "actions", + label: "操作", + render: (item) => , + width: "minmax(120px, 0.8fr)" + } + : null + ].filter(Boolean); + const toolbarActions = page.abilities.canCreate + ? [{ icon: , label: "添加币商", onClick: page.openCreateSeller, variant: "primary" }] + : []; + + return ( +
+
+ + + +
+ item.userId} /> + {total > 0 ? : null} +
+
+
+ + + page.setCreateForm({ ...page.createForm, targetUserId: event.target.value })} /> + page.setCreateForm({ ...page.createForm, reason: event.target.value })} /> + + + + + page.setEditForm({ ...page.editForm, status: event.target.value })}> + 启用 + 停用 + + page.setEditForm({ ...page.editForm, reason: event.target.value })} /> + +
+ ); +} + +function SellerIdentity({ item }) { + const name = item.username || "-"; + const meta = item.displayUserId || item.userId; + + return ( +
+ {item.avatar ? : {name.slice(0, 1).toUpperCase()}} + + {name} + {meta} + +
+ ); +} + +function SellerStatusSwitch({ item, page }) { + return ( + page.toggleSeller(item, event.target.checked)} + /> + ); +} + +function SellerActions({ item, page }) { + const deleting = page.loadingAction === `coin-seller-delete-${item.userId}`; + + return ( + + page.openEditSeller(item)}> + + + page.deleteSeller(item)}> + + + + ); +} + +function regionName(item, regionOptions = []) { + if (item.regionName) { + return item.regionName; + } + const option = regionOptions.find((region) => String(region.value) === String(item.regionId)); + return option?.name || item.regionId || "-"; +} + +function formatNumber(value) { + return Number(value || 0).toLocaleString("zh-CN"); +} diff --git a/src/features/host-org/pages/HostCountriesPage.jsx b/src/features/host-org/pages/HostCountriesPage.jsx new file mode 100644 index 0000000..df868b0 --- /dev/null +++ b/src/features/host-org/pages/HostCountriesPage.jsx @@ -0,0 +1,143 @@ +import Add from "@mui/icons-material/Add"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +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 { + AdminActionIconButton, + AdminFilterSelect, + AdminListBody, + AdminListPage, + AdminListToolbar, + AdminRowActions, + AdminSearchBox +} from "@/shared/ui/AdminListLayout.jsx"; +import { countryEnabledFilters } from "@/features/host-org/constants.js"; +import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx"; +import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; +import { useHostCountriesPage } from "@/features/host-org/hooks/useHostCountriesPage.js"; +import styles from "@/features/host-org/host-org.module.css"; + +const baseCountryColumns = [ + { key: "countryId", label: "国家 ID", width: "minmax(90px, 0.6fr)" }, + { key: "flag", label: "国旗", render: (item) => {item.flag || "-"}, width: "minmax(70px, 0.4fr)" }, + { key: "countryCode", label: "国家码", width: "minmax(90px, 0.7fr)" }, + { key: "countryDisplayName", label: "展示名称", width: "minmax(150px, 1fr)" }, + { key: "countryName", label: "国家名称", width: "minmax(150px, 1fr)" }, + { key: "phoneCountryCode", label: "电话区号", width: "minmax(90px, 0.7fr)" }, + { key: "sortOrder", label: "排序", width: "minmax(80px, 0.5fr)" }, + { key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" } +]; + +export function HostCountriesPage() { + const page = useHostCountriesPage(); + const createDisabled = !page.abilities.canCreate; + const updateDisabled = !page.abilities.canUpdate; + const items = page.data.items || []; + const total = page.data.total || 0; + const countryColumns = [ + ...baseCountryColumns.slice(0, 6), + { + key: "enabled", + label: "状态", + render: (item) => , + width: "minmax(90px, 0.6fr)" + }, + ...baseCountryColumns.slice(6) + ]; + const columns = page.abilities.canUpdate + ? [ + ...countryColumns, + { + key: "actions", + label: "操作", + render: (item) => , + width: "minmax(104px, 0.7fr)" + } + ] + : countryColumns; + return ( + + + + + + )} + actions={page.abilities.canCreate ? ( + + + + ) : null} + /> + + + + item.countryId} + /> +
+ {total} 条 +
+
+
+ + + page.setCountryForm({ ...page.countryForm, countryCode: event.target.value })} /> + page.setCountryForm({ ...page.countryForm, countryDisplayName: event.target.value })} /> + page.setCountryForm({ ...page.countryForm, countryName: event.target.value })} /> + page.setCountryForm({ ...page.countryForm, isoAlpha3: event.target.value })} /> + page.setCountryForm({ ...page.countryForm, isoNumeric: event.target.value })} /> + page.setCountryForm({ ...page.countryForm, phoneCountryCode: event.target.value })} /> + page.setCountryForm({ ...page.countryForm, flag: event.target.value })} /> + page.setCountryForm({ ...page.countryForm, sortOrder: event.target.value })} /> + {page.activeAction === "create" ? ( + page.setCountryForm({ ...page.countryForm, enabled: event.target.checked })} />} + label={page.countryForm.enabled ? "启用" : "停用"} + /> + ) : null} + + +
+ ); +} + +function CountryActions({ item, page }) { + return ( + + {page.abilities.canUpdate ? ( + page.openEditCountry(item)}> + + + ) : null} + + ); +} + +function CountryStatusSwitch({ item, page }) { + return ( + page.toggleCountry(item, event.target.checked)} + /> + ); +} diff --git a/src/features/host-org/pages/HostHostsPage.jsx b/src/features/host-org/pages/HostHostsPage.jsx new file mode 100644 index 0000000..8abadbe --- /dev/null +++ b/src/features/host-org/pages/HostHostsPage.jsx @@ -0,0 +1,66 @@ +import { formatMillis } from "@/shared/utils/time.js"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; +import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js"; +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 { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js"; +import styles from "@/features/host-org/host-org.module.css"; + +const hostColumns = [ + { key: "userId", label: "用户 ID", width: "minmax(110px, 0.8fr)" }, + { key: "status", label: "状态", render: (item) => , width: "minmax(90px, 0.7fr)" }, + { key: "regionId", label: "区域 ID", width: "minmax(90px, 0.7fr)" }, + { key: "currentAgencyId", label: "当前 Agency ID", render: (item) => item.currentAgencyId || "-" }, + { key: "currentMembershipId", label: "当前 Membership ID", render: (item) => item.currentMembershipId || "-" }, + { key: "source", label: "来源", render: (item) => hostSourceLabels[item.source] || item.source || "-", width: "minmax(150px, 1fr)" }, + { key: "firstBecameHostAtMs", label: "首次成为主播", render: (item) => formatMillis(item.firstBecameHostAtMs), width: "minmax(170px, 1fr)" }, + { key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" } +]; + +export function HostHostsPage() { + const page = useHostHostsPage(); + const items = page.data.items || []; + const total = page.data.total || 0; + + return ( +
+
+ + + +
+ item.userId} + /> + {total > 0 ? : null} +
+
+
+
+ ); +} diff --git a/src/features/host-org/pages/HostRegionsPage.jsx b/src/features/host-org/pages/HostRegionsPage.jsx new file mode 100644 index 0000000..8a2e8b6 --- /dev/null +++ b/src/features/host-org/pages/HostRegionsPage.jsx @@ -0,0 +1,194 @@ +import Add from "@mui/icons-material/Add"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import Autocomplete from "@mui/material/Autocomplete"; +import Switch from "@mui/material/Switch"; +import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; +import { formatMillis } from "@/shared/utils/time.js"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { IconButton } from "@/shared/ui/IconButton.jsx"; +import { SearchBox } from "@/shared/ui/SearchBox.jsx"; +import { StatusSelect } from "@/shared/ui/StatusSelect.jsx"; +import { regionStatusFilters } from "@/features/host-org/constants.js"; +import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx"; +import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; +import { useHostRegionsPage } from "@/features/host-org/hooks/useHostRegionsPage.js"; +import styles from "@/features/host-org/host-org.module.css"; + +const baseRegionColumns = [ + { key: "regionId", label: "区域 ID", width: "minmax(90px, 0.6fr)" }, + { key: "regionCode", label: "区域编码", width: "minmax(110px, 0.8fr)" }, + { key: "name", label: "区域名称", width: "minmax(150px, 1fr)" }, + { key: "countries", label: "国家码", render: (item) => , width: "minmax(220px, 1.5fr)" }, + { key: "sortOrder", label: "排序", width: "minmax(80px, 0.5fr)" }, + { key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" } +]; + +export function HostRegionsPage() { + const page = useHostRegionsPage(); + const createDisabled = !page.abilities.canCreate; + const updateDisabled = !page.abilities.canUpdate; + const items = page.data.items || []; + const total = page.data.total || 0; + const regionColumns = [ + ...baseRegionColumns.slice(0, 3), + { + key: "status", + label: "状态", + render: (item) => , + width: "minmax(90px, 0.6fr)" + }, + ...baseRegionColumns.slice(3) + ]; + const columns = page.abilities.canUpdate + ? [ + ...regionColumns, + { + key: "actions", + label: "操作", + render: (item) => , + width: "minmax(80px, 0.5fr)" + } + ] + : regionColumns; + return ( +
+
+
+
+
+ + +
+
+ + {page.abilities.canCreate ? ( +
+ + + + + + + +
+ ) : null} +
+ + +
+ item.regionId} + /> +
+ {total} 条 +
+
+
+
+ + + page.setRegionForm({ ...page.regionForm, regionCode: event.target.value })} /> + page.setRegionForm({ ...page.regionForm, name: event.target.value })} /> + page.setRegionForm({ ...page.regionForm, sortOrder: event.target.value })} /> + page.setRegionForm({ ...page.regionForm, countries })} + /> + + +
+ ); +} + +function RegionActions({ item, page }) { + return ( +
+ {page.abilities.canUpdate ? ( + + + page.openEditRegion(item)}> + + + + + ) : null} +
+ ); +} + +function RegionStatusSwitch({ item, page }) { + const checked = item.status === "active"; + return ( + page.toggleRegion(item, event.target.checked)} + /> + ); +} + +function CountryCodeSelect({ disabled, labels, loading, onChange, options, value }) { + const selected = value || []; + const mergedOptions = [...new Set([...options, ...selected])].sort(); + return ( + labels[option] || option} + loading={loading} + options={mergedOptions} + renderInput={(params) => } + size="small" + value={selected} + onChange={(_, countries) => onChange(countries)} + /> + ); +} + +function CountryCodes({ countries = [] }) { + if (!countries.length) { + return "-"; + } + return ( +
+ {countries.map((country) => ( + + {country} + + ))} +
+ ); +} + +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)" + } +}; diff --git a/src/features/host-org/permissions.js b/src/features/host-org/permissions.js new file mode 100644 index 0000000..816809a --- /dev/null +++ b/src/features/host-org/permissions.js @@ -0,0 +1,62 @@ +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { PERMISSIONS } from "@/app/permissions"; + +export function useCountryAbilities() { + const { can } = useAuth(); + + return { + canCreate: can(PERMISSIONS.countryCreate), + canStatus: can(PERMISSIONS.countryStatus), + canUpdate: can(PERMISSIONS.countryUpdate), + canView: can(PERMISSIONS.countryView) + }; +} + +export function useRegionAbilities() { + const { can } = useAuth(); + + return { + canCreate: can(PERMISSIONS.regionCreate), + canStatus: can(PERMISSIONS.regionStatus), + canUpdate: can(PERMISSIONS.regionUpdate), + canView: can(PERMISSIONS.regionView) + }; +} + +export function useAgencyAbilities() { + const { can } = useAuth(); + + return { + canCreate: can(PERMISSIONS.agencyCreate), + canStatus: can(PERMISSIONS.agencyStatus), + canView: can(PERMISSIONS.agencyView) + }; +} + +export function useHostAbilities() { + const { can } = useAuth(); + + return { + canView: can(PERMISSIONS.hostView) + }; +} + +export function useBDAbilities() { + const { can } = useAuth(); + + return { + canCreate: can(PERMISSIONS.bdCreate), + canUpdate: can(PERMISSIONS.bdUpdate), + canView: can(PERMISSIONS.bdView) + }; +} + +export function useCoinSellerAbilities() { + const { can } = useAuth(); + + return { + canCreate: can(PERMISSIONS.coinSellerCreate), + canUpdate: can(PERMISSIONS.coinSellerUpdate), + canView: can(PERMISSIONS.coinSellerView) + }; +} diff --git a/src/features/host-org/routes.js b/src/features/host-org/routes.js new file mode 100644 index 0000000..815ed7a --- /dev/null +++ b/src/features/host-org/routes.js @@ -0,0 +1,60 @@ +import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; + +export const hostOrgRoutes = [ + { + label: "国家管理", + loader: () => import("./pages/HostCountriesPage.jsx").then((module) => module.HostCountriesPage), + menuCode: MENU_CODES.hostOrgCountries, + pageKey: "host-org-countries", + path: "/host/countries", + permission: PERMISSIONS.countryView + }, + { + label: "区域管理", + loader: () => import("./pages/HostRegionsPage.jsx").then((module) => module.HostRegionsPage), + menuCode: MENU_CODES.hostOrgRegions, + pageKey: "host-org-regions", + path: "/host/regions", + permission: PERMISSIONS.regionView + }, + { + label: "Agency 管理", + loader: () => import("./pages/HostAgenciesPage.jsx").then((module) => module.HostAgenciesPage), + menuCode: MENU_CODES.hostOrgAgencies, + pageKey: "host-org-agencies", + path: "/host/agencies", + permission: PERMISSIONS.agencyView + }, + { + label: "BD Leader 管理", + loader: () => import("./pages/HostBdLeadersPage.jsx").then((module) => module.HostBdLeadersPage), + menuCode: MENU_CODES.hostOrgBdLeaders, + pageKey: "host-org-bd-leaders", + path: "/host/bd-leaders", + permission: PERMISSIONS.bdView + }, + { + label: "BD 管理", + loader: () => import("./pages/HostBdsPage.jsx").then((module) => module.HostBdsPage), + menuCode: MENU_CODES.hostOrgBds, + pageKey: "host-org-bds", + path: "/host/bds", + permission: PERMISSIONS.bdView + }, + { + label: "主播管理", + loader: () => import("./pages/HostHostsPage.jsx").then((module) => module.HostHostsPage), + menuCode: MENU_CODES.hostOrgHosts, + pageKey: "host-org-hosts", + path: "/host/hosts", + permission: PERMISSIONS.hostView + }, + { + label: "币商管理", + loader: () => import("./pages/HostCoinSellersPage.jsx").then((module) => module.HostCoinSellersPage), + menuCode: MENU_CODES.hostOrgCoinSellers, + pageKey: "host-org-coin-sellers", + path: "/host/coin-sellers", + permission: PERMISSIONS.coinSellerView + } +]; diff --git a/src/features/host-org/schema.ts b/src/features/host-org/schema.ts new file mode 100644 index 0000000..cce29b9 --- /dev/null +++ b/src/features/host-org/schema.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; + +const commandBaseSchema = z.object({ + commandId: z.string().trim().min(1, "请输入 Command ID").max(120, "Command ID 不能超过 120 个字符"), + reason: z.string().trim().max(200, "原因不能超过 200 个字符").optional().default("") +}); + +const commandContactSchema = z.object({ + commandId: z.string().trim().min(1, "请输入 Command ID").max(120, "Command ID 不能超过 120 个字符"), + reason: z.string().trim().max(200, "联系方式不能超过 200 个字符").optional().default("") +}); + +const userIdSchema = z.coerce.number().int().positive("请输入有效用户短 ID"); +const regionIdSchema = z.coerce.number().int().positive("请输入有效区域 ID"); +const sortOrderSchema = z.coerce.number().int().min(0, "排序不能小于 0").default(0); +const optionalTextSchema = (max: number, message: string) => z.string().trim().max(max, message).optional().default(""); +const codeSchema = (label: string) => z.string().trim().min(1, `请输入${label}`).max(40, `${label}不能超过 40 个字符`); +const countryCodesSchema = z.array(z.string()).transform((values, ctx) => { + const countries = values + .map((item) => item.trim().toUpperCase()) + .filter(Boolean); + if (!countries.length) { + ctx.addIssue({ code: "custom", message: "请选择国家码" }); + return countries; + } + if (new Set(countries).size !== countries.length) { + ctx.addIssue({ code: "custom", message: "国家码不能重复" }); + } + return countries; +}); + +export const createBDLeaderSchema = commandContactSchema.extend({ + regionId: regionIdSchema, + targetUserId: userIdSchema +}); + +export const createBDSchema = commandBaseSchema.extend({ + parentLeaderUserId: userIdSchema, + targetUserId: userIdSchema +}); + +export const bdStatusSchema = commandBaseSchema.extend({ + status: z.string().trim().min(1, "请输入状态").max(40, "状态不能超过 40 个字符"), + targetType: z.enum(["bd", "leader"]), + targetUserId: userIdSchema +}); + +export const createCoinSellerSchema = commandContactSchema.extend({ + targetUserId: userIdSchema +}); + +export const coinSellerStatusSchema = commandContactSchema.extend({ + status: z.enum(["active", "disabled"]), + targetUserId: userIdSchema +}); + +export const createAgencySchema = commandBaseSchema.extend({ + joinEnabled: z.boolean().default(true), + maxHosts: z.coerce.number().int().min(0, "最大主播数不能小于 0").default(0), + name: z.string().trim().min(1, "请输入 Agency 名称").max(80, "Agency 名称不能超过 80 个字符"), + ownerUserId: userIdSchema, + parentBdUserId: userIdSchema +}); + +export const agencyCloseSchema = commandBaseSchema.extend({ + agencyId: z.coerce.number().int().positive("请输入有效 Agency ID") +}); + +export const agencyJoinEnabledSchema = commandBaseSchema.extend({ + agencyId: z.coerce.number().int().positive("请输入有效 Agency ID"), + joinEnabled: z.boolean().default(true) +}); + +export const countryCreateSchema = z.object({ + countryCode: z.string().trim().regex(/^[A-Za-z]{2,3}$/, "国家码必须是 2-3 位字母").transform((value) => value.toUpperCase()), + countryDisplayName: z.string().trim().min(1, "请输入展示名称").max(80, "展示名称不能超过 80 个字符"), + countryName: z.string().trim().min(1, "请输入国家名称").max(80, "国家名称不能超过 80 个字符"), + enabled: z.boolean().default(true), + flag: optionalTextSchema(16, "国旗不能超过 16 个字符"), + isoAlpha3: optionalTextSchema(8, "ISO Alpha-3 不能超过 8 个字符").transform((value) => value.toUpperCase()), + isoNumeric: optionalTextSchema(8, "ISO Numeric 不能超过 8 个字符"), + phoneCountryCode: optionalTextSchema(16, "电话区号不能超过 16 个字符"), + sortOrder: sortOrderSchema +}); + +export const countryUpdateSchema = countryCreateSchema.omit({ + countryCode: true, + enabled: true +}); + +export const regionCreateSchema = z.object({ + countries: countryCodesSchema, + name: z.string().trim().min(1, "请输入区域名称").max(80, "区域名称不能超过 80 个字符"), + regionCode: codeSchema("区域编码").transform((value) => value.toUpperCase()), + sortOrder: sortOrderSchema +}); + +export const regionUpdateSchema = z.object({ + countries: countryCodesSchema, + name: z.string().trim().min(1, "请输入区域名称").max(80, "区域名称不能超过 80 个字符"), + regionCode: codeSchema("区域编码").transform((value) => value.toUpperCase()), + sortOrder: sortOrderSchema +}); + +export const regionCountriesSchema = z.object({ + countries: countryCodesSchema +}); + +export type CreateBDLeaderForm = z.infer; +export type CreateBDForm = z.infer; +export type BDStatusForm = z.infer; +export type CreateCoinSellerForm = z.infer; +export type CoinSellerStatusForm = z.infer; +export type CreateAgencyForm = z.infer; +export type AgencyCloseForm = z.infer; +export type AgencyJoinEnabledForm = z.infer; +export type CountryCreateForm = z.infer; +export type CountryUpdateForm = z.infer; +export type RegionCreateForm = z.infer; +export type RegionUpdateForm = z.infer; +export type RegionCountriesForm = z.infer; diff --git a/src/features/logs/api.ts b/src/features/logs/api.ts new file mode 100644 index 0000000..f680b98 --- /dev/null +++ b/src/features/logs/api.ts @@ -0,0 +1,19 @@ +import { apiRequest } from "@/shared/api/request"; +import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import type { ApiPage, LogDto, PageQuery } from "@/shared/api/types"; + +export function listLoginLogs(query?: PageQuery): Promise> { + return apiRequest>(apiEndpointPath(API_OPERATIONS.listLoginLogs), { query }); +} + +export function listOperationLogs(query?: PageQuery): Promise> { + return apiRequest>(apiEndpointPath(API_OPERATIONS.listOperationLogs), { query }); +} + +export function exportLoginLogs(query?: PageQuery): Promise { + return apiRequest(apiEndpointPath(API_OPERATIONS.exportLoginLogs), { query, raw: true }); +} + +export function exportOperationLogs(query?: PageQuery): Promise { + return apiRequest(apiEndpointPath(API_OPERATIONS.exportOperationLogs), { query, raw: true }); +} diff --git a/src/features/logs/components/LogFilters.jsx b/src/features/logs/components/LogFilters.jsx new file mode 100644 index 0000000..ad9b547 --- /dev/null +++ b/src/features/logs/components/LogFilters.jsx @@ -0,0 +1,9 @@ +import { SearchBox } from "@/shared/ui/SearchBox.jsx"; + +export function LogFilters({ onQueryChange, query }) { + return ( +
+ +
+ ); +} diff --git a/src/features/logs/components/LogTable.jsx b/src/features/logs/components/LogTable.jsx new file mode 100644 index 0000000..a940510 --- /dev/null +++ b/src/features/logs/components/LogTable.jsx @@ -0,0 +1,64 @@ +import { DataTable } from "@/shared/ui/DataTable.jsx"; + +export function LogTable({ data, isLogin }) { + const items = data.items || []; + const columns = isLogin + ? [ + { key: "username", label: "用户", width: "minmax(140px, 1fr)" }, + { key: "ip", label: "IP", width: "minmax(140px, 1fr)", render: (item) => {item.ip || "-"} }, + { key: "status", label: "状态", width: "110px" }, + { + key: "message", + label: "消息", + width: "minmax(220px, 1.5fr)", + render: (item) => {item.message || "-"} + }, + { + key: "createdAt", + label: "时间", + width: "180px", + render: (item) => {formatDate(item.createdAt)} + } + ] + : [ + { key: "username", label: "用户", width: "minmax(140px, 1fr)" }, + { key: "action", label: "动作", width: "minmax(120px, 1fr)" }, + { + key: "resource", + label: "资源", + width: "minmax(160px, 1fr)", + render: (item) => {item.resource || "-"} + }, + { key: "status", label: "状态", width: "110px" }, + { + key: "detail", + label: "详情", + width: "minmax(240px, 1.6fr)", + render: (item) => {item.detail || "-"} + }, + { + key: "createdAt", + label: "时间", + width: "180px", + render: (item) => {formatDate(item.createdAt)} + } + ]; + + return ( + item.id} + title={isLogin ? "登录记录" : "操作记录"} + total={data.total} + /> + ); +} + +function formatDate(value) { + if (!value) { + return "-"; + } + return new Date(value).toLocaleString("zh-CN", { hour12: false }); +} diff --git a/src/features/logs/components/LogToolbar.jsx b/src/features/logs/components/LogToolbar.jsx new file mode 100644 index 0000000..efe7b61 --- /dev/null +++ b/src/features/logs/components/LogToolbar.jsx @@ -0,0 +1,16 @@ +import DownloadOutlined from "@mui/icons-material/DownloadOutlined"; +import { Button } from "@/shared/ui/Button.jsx"; +import { PageHead } from "@/shared/ui/PageHead.jsx"; + +export function LogToolbar({ canExport, isLogin, onExport }) { + return ( + + {canExport ? ( + + ) : null} + + ); +} diff --git a/src/features/logs/hooks/useLogsPage.js b/src/features/logs/hooks/useLogsPage.js new file mode 100644 index 0000000..87295ce --- /dev/null +++ b/src/features/logs/hooks/useLogsPage.js @@ -0,0 +1,54 @@ +import { useCallback, useMemo, useState } from "react"; +import { downloadCsv } from "@/shared/api/download"; +import { toPageQuery } from "@/shared/api/query"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { exportLoginLogs, exportOperationLogs, listLoginLogs, listOperationLogs } from "@/features/logs/api"; +import { useLogAbilities } from "@/features/logs/permissions.js"; + +const pageSize = 10; +const emptyData = { items: [], total: 0, page: 1, pageSize }; + +export function useLogsPage(type) { + const [query, setQuery] = useState(""); + const [page, setPage] = useState(1); + const abilities = useLogAbilities(); + const isLogin = type === "login"; + + const requestQuery = useMemo(() => toPageQuery({ keyword: query, page, pageSize }), [page, query]); + const queryFn = useCallback(async () => { + const loader = isLogin ? listLoginLogs : listOperationLogs; + return loader(requestQuery); + }, [isLogin, requestQuery]); + + const { data, error, loading, reload } = useAdminQuery(queryFn, { + errorMessage: "加载日志失败", + initialData: emptyData, + keepPreviousData: true, + queryKey: ["logs", type, requestQuery] + }); + + const changeQuery = (value) => { + setQuery(value); + setPage(1); + }; + + const downloadLogs = () => { + const exporter = isLogin ? exportLoginLogs : exportOperationLogs; + const filename = isLogin ? "hyapp-login-logs.csv" : "hyapp-operation-logs.csv"; + return downloadCsv(() => exporter({ keyword: query }), filename); + }; + + return { + abilities, + changeQuery, + data, + downloadLogs, + error, + isLogin, + loading, + page, + query, + reload, + setPage + }; +} diff --git a/src/features/logs/pages/LogsPage.jsx b/src/features/logs/pages/LogsPage.jsx new file mode 100644 index 0000000..b047ace --- /dev/null +++ b/src/features/logs/pages/LogsPage.jsx @@ -0,0 +1,22 @@ +import { DataState } from "@/shared/ui/DataState.jsx"; +import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; +import { LogFilters } from "@/features/logs/components/LogFilters.jsx"; +import { LogTable } from "@/features/logs/components/LogTable.jsx"; +import { LogToolbar } from "@/features/logs/components/LogToolbar.jsx"; +import { useLogsPage } from "@/features/logs/hooks/useLogsPage.js"; + +export function LogsPage({ type }) { + const page = useLogsPage(type); + + return ( + <> + + + + + + + + + ); +} diff --git a/src/features/logs/permissions.js b/src/features/logs/permissions.js new file mode 100644 index 0000000..a488e65 --- /dev/null +++ b/src/features/logs/permissions.js @@ -0,0 +1,10 @@ +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { PERMISSIONS } from "@/app/permissions"; + +export function useLogAbilities() { + const { can } = useAuth(); + + return { + canExport: can(PERMISSIONS.logExport) + }; +} diff --git a/src/features/logs/routes.js b/src/features/logs/routes.js new file mode 100644 index 0000000..06e2cc2 --- /dev/null +++ b/src/features/logs/routes.js @@ -0,0 +1,22 @@ +import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; + +export const logsRoutes = [ + { + label: "登录日志", + loader: () => import("./pages/LogsPage.jsx").then((module) => module.LogsPage), + menuCode: MENU_CODES.logsLogin, + pageKey: "logs-login", + path: "/logs/login", + permission: PERMISSIONS.logView, + props: { type: "login" } + }, + { + label: "操作日志", + loader: () => import("./pages/LogsPage.jsx").then((module) => module.LogsPage), + menuCode: MENU_CODES.logsOperations, + pageKey: "logs-operations", + path: "/logs/operations", + permission: PERMISSIONS.logView, + props: { type: "operations" } + } +]; diff --git a/src/features/menus/api.ts b/src/features/menus/api.ts new file mode 100644 index 0000000..aae7e06 --- /dev/null +++ b/src/features/menus/api.ts @@ -0,0 +1,81 @@ +import { apiRequest } from "@/shared/api/request"; +import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import type { EntityId, MenuDto, MenuPayload, MenuSortItem, PermissionDto, PermissionPayload } from "@/shared/api/types"; + +export function getMenus(): Promise { + return apiRequest(apiEndpointPath(API_OPERATIONS.navigationMenus)); +} + +export function listSystemMenus(): Promise { + return apiRequest(apiEndpointPath(API_OPERATIONS.listSystemMenus)); +} + +export function createMenu(payload: MenuPayload): Promise { + const endpoint = API_ENDPOINTS.createMenu; + return apiRequest(apiEndpointPath(API_OPERATIONS.createMenu), { + body: payload, + method: endpoint.method + }); +} + +export function updateMenu(id: EntityId, payload: MenuPayload): Promise { + const endpoint = API_ENDPOINTS.updateMenu; + return apiRequest(apiEndpointPath(API_OPERATIONS.updateMenu, { id }), { + body: payload, + method: endpoint.method + }); +} + +export function deleteMenu(id: EntityId): Promise { + const endpoint = API_ENDPOINTS.deleteMenu; + return apiRequest(apiEndpointPath(API_OPERATIONS.deleteMenu, { id }), { method: endpoint.method }); +} + +export function updateMenuVisible(id: EntityId, visible: boolean): Promise { + const endpoint = API_ENDPOINTS.updateMenuVisible; + return apiRequest(apiEndpointPath(API_OPERATIONS.updateMenuVisible, { id }), { + body: { visible }, + method: endpoint.method + }); +} + +export function sortMenus(items: MenuSortItem[]): Promise { + const endpoint = API_ENDPOINTS.sortMenus; + return apiRequest(apiEndpointPath(API_OPERATIONS.sortMenus), { + body: { items }, + method: endpoint.method + }); +} + +export function listPermissions(): Promise { + return apiRequest(apiEndpointPath(API_OPERATIONS.listPermissions)); +} + +export function createPermission(payload: PermissionPayload): Promise { + const endpoint = API_ENDPOINTS.createPermission; + return apiRequest(apiEndpointPath(API_OPERATIONS.createPermission), { + body: payload, + method: endpoint.method + }); +} + +export function updatePermission(id: EntityId, payload: PermissionPayload): Promise { + const endpoint = API_ENDPOINTS.updatePermission; + return apiRequest(apiEndpointPath(API_OPERATIONS.updatePermission, { id }), { + body: payload, + method: endpoint.method + }); +} + +export function deletePermission(id: EntityId): Promise { + const endpoint = API_ENDPOINTS.deletePermission; + return apiRequest(apiEndpointPath(API_OPERATIONS.deletePermission, { id }), { method: endpoint.method }); +} + +export function syncPermissions(): Promise { + const endpoint = API_ENDPOINTS.syncPermissions; + return apiRequest>(apiEndpointPath(API_OPERATIONS.syncPermissions), { + body: {}, + method: endpoint.method + }); +} diff --git a/src/features/menus/components/MenuFormDrawer.jsx b/src/features/menus/components/MenuFormDrawer.jsx new file mode 100644 index 0000000..9832a2c --- /dev/null +++ b/src/features/menus/components/MenuFormDrawer.jsx @@ -0,0 +1,30 @@ +import Drawer from "@mui/material/Drawer"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Switch from "@mui/material/Switch"; +import TextField from "@mui/material/TextField"; +import { Button } from "@/shared/ui/Button.jsx"; + +export function MenuFormDrawer({ editingMenu, form, onClose, onSubmit, open, setForm }) { + return ( + +
+

{editingMenu ? "编辑菜单" : "新增菜单"}

+ setForm({ ...form, label: event.target.value })} /> + setForm({ ...form, code: event.target.value })} /> + setForm({ ...form, parentId: event.target.value })} /> + setForm({ ...form, path: event.target.value })} /> + setForm({ ...form, icon: event.target.value })} /> + setForm({ ...form, permissionCode: event.target.value })} /> + setForm({ ...form, sort: event.target.value })} /> + setForm({ ...form, visible: event.target.checked })} />} + label={form.visible ? "显示" : "隐藏"} + /> +
+ + +
+ +
+ ); +} diff --git a/src/features/menus/components/MenuStats.jsx b/src/features/menus/components/MenuStats.jsx new file mode 100644 index 0000000..75ce85c --- /dev/null +++ b/src/features/menus/components/MenuStats.jsx @@ -0,0 +1,12 @@ +import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined"; +import ShieldOutlined from "@mui/icons-material/ShieldOutlined"; +import { KpiCard } from "@/shared/ui/KpiCard.jsx"; + +export function MenuStats({ menuTotal, permissionTotal }) { + return ( +
+ 菜单树} /> + 菜单 / 按钮 / 接口} /> +
+ ); +} diff --git a/src/features/menus/components/MenuTable.jsx b/src/features/menus/components/MenuTable.jsx new file mode 100644 index 0000000..7c7211b --- /dev/null +++ b/src/features/menus/components/MenuTable.jsx @@ -0,0 +1,121 @@ +import { useState } from "react"; +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; +import DragIndicatorOutlined from "@mui/icons-material/DragIndicatorOutlined"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import VisibilityOffOutlined from "@mui/icons-material/VisibilityOffOutlined"; +import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined"; +import { + AdminActionIconButton, + AdminRowActions, + adminListClasses +} from "@/shared/ui/AdminListLayout.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; + +export function MenuTable({ abilities, flatMenus, onEdit, onReorder, onRemove, onToggleVisible }) { + const [draggingMenu, setDraggingMenu] = useState(null); + const [dragOverId, setDragOverId] = useState(null); + const columns = [ + { + key: "drag", + header: "", + width: "44px", + render: () => abilities.canSortMenu ? : null + }, + { + key: "label", + label: "菜单", + width: "minmax(180px, 1.2fr)", + render: (menu) => {menu.label} + }, + { key: "path", label: "路由", width: "minmax(180px, 1.2fr)", render: (menu) => {menu.path || "-"} }, + { key: "permissionCode", label: "权限码", width: "minmax(180px, 1.2fr)", render: (menu) => menu.permissionCode || "-" }, + { key: "sort", label: "排序", width: "88px", render: (menu) => {menu.sort} }, + { key: "visible", label: "状态", width: "100px", render: (menu) => (menu.visible ? "显示" : "隐藏") }, + { + key: "actions", + label: "操作", + width: "minmax(150px, 0.8fr)", + render: (menu) => ( + + {abilities.canUpdateMenu ? ( + onEdit(menu)}> + + + ) : null} + {abilities.canVisibleMenu ? ( + onToggleVisible(menu)}> + {menu.visible ? : } + + ) : null} + {abilities.canDeleteMenu ? ( + onRemove(menu)}> + + + ) : null} + + ) + } + ]; + + return ( + getDragRowProps({ + canSort: abilities.canSortMenu, + dragOverId, + draggingMenu, + menu, + onDragEnd: () => { + setDraggingMenu(null); + setDragOverId(null); + }, + onDragOverRow: setDragOverId, + onDragStart: setDraggingMenu, + onReorder + })} + items={flatMenus} + minWidth="1120px" + rowKey={(menu) => menu.id || menu.code} + /> + ); +} + +function getDragRowProps({ canSort, dragOverId, draggingMenu, menu, onDragEnd, onDragOverRow, onDragStart, onReorder }) { + if (!canSort) { + return {}; + } + + const canDrop = draggingMenu && draggingMenu.id !== menu.id && (draggingMenu.parentId || null) === (menu.parentId || null); + const isDropTarget = canDrop && dragOverId === menu.id; + + return { + className: [adminListClasses.dragRow, isDropTarget ? adminListClasses.dragRowActive : ""].filter(Boolean).join(" "), + draggable: true, + onDragEnd, + onDragLeave: () => { + if (dragOverId === menu.id) { + onDragOverRow(null); + } + }, + onDragOver: (event) => { + if (canDrop) { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + onDragOverRow(menu.id); + } + }, + onDragStart: (event) => { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", String(menu.id)); + onDragStart(menu); + }, + onDrop: (event) => { + event.preventDefault(); + if (canDrop) { + onReorder(draggingMenu, menu); + } + onDragOverRow(null); + onDragEnd(); + } + }; +} diff --git a/src/features/menus/components/MenuToolbar.jsx b/src/features/menus/components/MenuToolbar.jsx new file mode 100644 index 0000000..70428eb --- /dev/null +++ b/src/features/menus/components/MenuToolbar.jsx @@ -0,0 +1,29 @@ +import Add from "@mui/icons-material/Add"; +import SyncOutlined from "@mui/icons-material/SyncOutlined"; +import { Button } from "@/shared/ui/Button.jsx"; +import { PageHead } from "@/shared/ui/PageHead.jsx"; + +export function MenuToolbar({ abilities, onCreateMenu, onCreatePermission, onSyncPermissions }) { + return ( + + {abilities.canSyncPermission ? ( + + ) : null} + {abilities.canCreatePermission ? ( + + ) : null} + {abilities.canCreateMenu ? ( + + ) : null} + + ); +} diff --git a/src/features/menus/components/PermissionFormDrawer.jsx b/src/features/menus/components/PermissionFormDrawer.jsx new file mode 100644 index 0000000..49d3b00 --- /dev/null +++ b/src/features/menus/components/PermissionFormDrawer.jsx @@ -0,0 +1,27 @@ +import Drawer from "@mui/material/Drawer"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { Button } from "@/shared/ui/Button.jsx"; +import { permissionKinds } from "@/features/menus/constants.js"; + +export function PermissionFormDrawer({ editingPermission, form, onClose, onSubmit, open, setForm }) { + return ( + +
+

{editingPermission ? "编辑权限" : "新增权限"}

+ setForm({ ...form, name: event.target.value })} /> + setForm({ ...form, code: event.target.value })} /> + setForm({ ...form, kind: event.target.value })}> + {permissionKinds.map(([value, label]) => ( + {label} + ))} + + setForm({ ...form, description: event.target.value })} /> +
+ + +
+ +
+ ); +} diff --git a/src/features/menus/components/PermissionTable.jsx b/src/features/menus/components/PermissionTable.jsx new file mode 100644 index 0000000..8537ff9 --- /dev/null +++ b/src/features/menus/components/PermissionTable.jsx @@ -0,0 +1,51 @@ +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { kindLabel } from "@/features/menus/constants.js"; + +export function PermissionTable({ abilities, onEdit, onRemove, permissions }) { + if (!abilities.canLoadPermissions) { + return null; + } + + const columns = [ + { key: "name", label: "权限", width: "minmax(160px, 1fr)" }, + { key: "code", label: "编码", width: "minmax(180px, 1.2fr)", render: (permission) => {permission.code} }, + { key: "kind", label: "类型", width: "110px", render: (permission) => kindLabel(permission.kind) }, + { + key: "description", + label: "说明", + width: "minmax(240px, 1.6fr)", + render: (permission) => {permission.description || "-"} + }, + { + key: "actions", + label: "操作", + width: "120px", + render: (permission) => ( + + {abilities.canUpdatePermission ? ( + onEdit(permission)}> + + + ) : null} + {abilities.canDeletePermission ? ( + onRemove(permission)}> + + + ) : null} + + ) + } + ]; + + return ( + permission.id} + /> + ); +} diff --git a/src/features/menus/constants.js b/src/features/menus/constants.js new file mode 100644 index 0000000..39743ba --- /dev/null +++ b/src/features/menus/constants.js @@ -0,0 +1,9 @@ +export const permissionKinds = [ + ["menu", "菜单"], + ["button", "按钮"], + ["api", "接口"] +]; + +export function kindLabel(kind) { + return permissionKinds.find(([value]) => value === kind)?.[1] || kind; +} diff --git a/src/features/menus/hooks/useMenusPage.js b/src/features/menus/hooks/useMenusPage.js new file mode 100644 index 0000000..13f718d --- /dev/null +++ b/src/features/menus/hooks/useMenusPage.js @@ -0,0 +1,276 @@ +import { useCallback, useMemo, useState } from "react"; +import { parseForm } from "@/shared/forms/validation"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { + createMenu, + createPermission, + deleteMenu, + deletePermission, + listPermissions, + listSystemMenus, + sortMenus, + syncPermissions, + updateMenu, + updateMenuVisible, + updatePermission +} from "@/features/menus/api"; +import { useMenuAbilities } from "@/features/menus/permissions.js"; +import { menuFormSchema, permissionFormSchema } from "@/features/menus/schema"; + +const emptyMenu = { + code: "", + icon: "menu", + label: "", + parentId: "", + path: "", + permissionCode: "", + sort: 0, + visible: true +}; + +const emptyPermission = { code: "", description: "", kind: "button", name: "" }; +const emptyData = { menus: [], permissions: [] }; + +export function useMenusPage() { + const [query, setQuery] = useState(""); + const [menuDrawerOpen, setMenuDrawerOpen] = useState(false); + const [permissionDrawerOpen, setPermissionDrawerOpen] = useState(false); + const [editingMenu, setEditingMenu] = useState(null); + const [editingPermission, setEditingPermission] = useState(null); + const [menuForm, setMenuForm] = useState(emptyMenu); + const [permissionForm, setPermissionForm] = useState(emptyPermission); + const abilities = useMenuAbilities(); + const confirm = useConfirm(); + const { showToast } = useToast(); + + const queryFn = useCallback(async () => { + const menuData = await listSystemMenus(); + const permissionData = abilities.canLoadPermissions ? await listPermissions() : []; + return { menus: menuData || [], permissions: permissionData || [] }; + }, [abilities.canLoadPermissions]); + + const { data, error, loading, reload } = useAdminQuery(queryFn, { + errorMessage: "加载菜单权限失败", + initialData: emptyData, + keepPreviousData: true, + queryKey: ["menus", abilities.canLoadPermissions] + }); + + const flatMenus = useMemo(() => flattenMenus(data.menus || []), [data.menus]); + const filteredFlatMenus = useMemo(() => { + const keyword = query.trim().toLowerCase(); + if (!keyword) { + return flatMenus; + } + return flatMenus.filter((menu) => { + return [menu.label, menu.code, menu.path, menu.permissionCode].some((value) => String(value || "").toLowerCase().includes(keyword)); + }); + }, [flatMenus, query]); + const filteredPermissions = useMemo(() => { + const keyword = query.trim().toLowerCase(); + const items = data.permissions || []; + if (!keyword) { + return items; + } + return items.filter((permission) => { + return [permission.name, permission.code, permission.kind, permission.description].some((value) => String(value || "").toLowerCase().includes(keyword)); + }); + }, [data.permissions, query]); + + const changeQuery = (value) => { + setQuery(value); + }; + + const openCreateMenu = () => { + setEditingMenu(null); + setMenuForm(emptyMenu); + setMenuDrawerOpen(true); + }; + + const openEditMenu = (menu) => { + setEditingMenu(menu); + setMenuForm({ + code: menu.code, + icon: menu.icon || "menu", + label: menu.label, + parentId: menu.parentId ? String(menu.parentId) : "", + path: menu.path || "", + permissionCode: menu.permissionCode || "", + sort: menu.sort || 0, + visible: Boolean(menu.visible) + }); + setMenuDrawerOpen(true); + }; + + const openCreatePermission = () => { + setEditingPermission(null); + setPermissionForm(emptyPermission); + setPermissionDrawerOpen(true); + }; + + const openEditPermission = (permission) => { + setEditingPermission(permission); + setPermissionForm({ + code: permission.code, + description: permission.description || "", + kind: permission.kind, + name: permission.name + }); + setPermissionDrawerOpen(true); + }; + + const submitMenu = async (event) => { + event.preventDefault(); + try { + const payload = parseForm(menuFormSchema, menuForm); + if (editingMenu) { + await updateMenu(editingMenu.id, payload); + showToast("菜单已更新", "success"); + } else { + await createMenu(payload); + showToast("菜单已创建", "success"); + } + setMenuDrawerOpen(false); + reload(); + } catch (err) { + showToast(err.message || "保存菜单失败", "error"); + } + }; + + const submitPermission = async (event) => { + event.preventDefault(); + try { + const payload = parseForm(permissionFormSchema, permissionForm); + if (editingPermission) { + await updatePermission(editingPermission.id, payload); + showToast("权限已更新", "success"); + } else { + await createPermission(payload); + showToast("权限已创建", "success"); + } + setPermissionDrawerOpen(false); + reload(); + } catch (err) { + showToast(err.message || "保存权限失败", "error"); + } + }; + + const removeMenu = async (menu) => { + const ok = await confirm({ confirmText: "删除", message: `${menu.label} 删除前需要先删除子菜单。`, title: "删除菜单", tone: "danger" }); + if (!ok) { + return; + } + try { + await deleteMenu(menu.id); + showToast("菜单已删除", "success"); + reload(); + } catch (err) { + showToast(err.message || "删除菜单失败", "error"); + } + }; + + const removePermission = async (permission) => { + const ok = await confirm({ confirmText: "删除", message: `${permission.name} 删除前不能被角色或菜单引用。`, title: "删除权限", tone: "danger" }); + if (!ok) { + return; + } + try { + await deletePermission(permission.id); + showToast("权限已删除", "success"); + reload(); + } catch (err) { + showToast(err.message || "删除权限失败", "error"); + } + }; + + const toggleVisible = async (menu) => { + try { + await updateMenuVisible(menu.id, !menu.visible); + showToast("菜单状态已更新", "success"); + reload(); + } catch (err) { + showToast(err.message || "更新菜单失败", "error"); + } + }; + + const reorderMenu = async (source, target) => { + if (!source || !target || source.id === target.id || (source.parentId || null) !== (target.parentId || null)) { + return; + } + const siblings = flatMenus + .filter((item) => (item.parentId || null) === (source.parentId || null)) + .sort((a, b) => (a.sort || 0) - (b.sort || 0) || a.id - b.id); + const fromIndex = siblings.findIndex((item) => item.id === source.id); + const toIndex = siblings.findIndex((item) => item.id === target.id); + if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) { + return; + } + const reordered = [...siblings]; + const [moved] = reordered.splice(fromIndex, 1); + reordered.splice(toIndex, 0, moved); + + try { + await sortMenus(reordered.map((item, index) => ({ id: item.id, sort: (index + 1) * 10 }))); + showToast("菜单排序已更新", "success"); + reload(); + } catch (err) { + showToast(err.message || "排序失败", "error"); + } + }; + + const syncDefaultPermissions = async () => { + const ok = await confirm({ confirmText: "同步", message: "将同步系统默认权限定义。", title: "同步权限" }); + if (!ok) { + return; + } + try { + await syncPermissions(); + showToast("权限已同步", "success"); + reload(); + } catch (err) { + showToast(err.message || "同步权限失败", "error"); + } + }; + + return { + abilities, + changeQuery, + closeMenuDrawer: () => setMenuDrawerOpen(false), + closePermissionDrawer: () => setPermissionDrawerOpen(false), + editingMenu, + editingPermission, + error, + flatMenus: filteredFlatMenus, + loading, + menuDrawerOpen, + menuForm, + menus: data.menus || [], + openCreateMenu, + openCreatePermission, + openEditMenu, + openEditPermission, + permissionDrawerOpen, + permissionForm, + permissions: filteredPermissions, + query, + reload, + removeMenu, + removePermission, + reorderMenu, + setMenuForm, + setPermissionForm, + submitMenu, + submitPermission, + syncDefaultPermissions, + toggleVisible + }; +} + +function flattenMenus(menus, depth = 0) { + return menus.flatMap((menu) => [ + { ...menu, depth }, + ...flattenMenus(menu.children || [], depth + 1) + ]); +} diff --git a/src/features/menus/menus.css b/src/features/menus/menus.css new file mode 100644 index 0000000..aa35c57 --- /dev/null +++ b/src/features/menus/menus.css @@ -0,0 +1,13 @@ +.permission-page-grid { + display: grid; + flex: 1; + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); + gap: 0; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.permission-page-grid .table-frame + .table-frame { + border-top: 1px solid var(--border); +} diff --git a/src/features/menus/pages/MenuManagementPage.jsx b/src/features/menus/pages/MenuManagementPage.jsx new file mode 100644 index 0000000..29c3e7f --- /dev/null +++ b/src/features/menus/pages/MenuManagementPage.jsx @@ -0,0 +1,86 @@ +import Add from "@mui/icons-material/Add"; +import SyncOutlined from "@mui/icons-material/SyncOutlined"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { + AdminActionIconButton, + AdminListBody, + AdminListPage, + AdminListToolbar, + AdminSearchBox +} from "@/shared/ui/AdminListLayout.jsx"; +import { MenuFormDrawer } from "@/features/menus/components/MenuFormDrawer.jsx"; +import { MenuTable } from "@/features/menus/components/MenuTable.jsx"; +import { PermissionFormDrawer } from "@/features/menus/components/PermissionFormDrawer.jsx"; +import { PermissionTable } from "@/features/menus/components/PermissionTable.jsx"; +import { useMenusPage } from "@/features/menus/hooks/useMenusPage.js"; + +export function MenuManagementPage() { + const page = useMenusPage(); + + return ( + <> + + } + actions={( + <> + {page.abilities.canSyncPermission ? ( + + + + ) : null} + {page.abilities.canCreatePermission ? ( + + + + ) : null} + {page.abilities.canCreateMenu ? ( + + + + ) : null} + + )} + /> + + + +
+ + +
+
+
+
+ + + + + ); +} diff --git a/src/features/menus/permissions.js b/src/features/menus/permissions.js new file mode 100644 index 0000000..a8e41b9 --- /dev/null +++ b/src/features/menus/permissions.js @@ -0,0 +1,19 @@ +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { PERMISSIONS } from "@/app/permissions"; + +export function useMenuAbilities() { + const { can } = useAuth(); + + return { + canCreateMenu: can(PERMISSIONS.menuCreate), + canCreatePermission: can(PERMISSIONS.permissionCreate), + canDeleteMenu: can(PERMISSIONS.menuDelete), + canDeletePermission: can(PERMISSIONS.permissionDelete), + canLoadPermissions: can(PERMISSIONS.permissionView) || can(PERMISSIONS.rolePermission) || can(PERMISSIONS.roleManage), + canSortMenu: can(PERMISSIONS.menuSort), + canSyncPermission: can(PERMISSIONS.permissionSync), + canUpdateMenu: can(PERMISSIONS.menuUpdate), + canUpdatePermission: can(PERMISSIONS.permissionUpdate), + canVisibleMenu: can(PERMISSIONS.menuVisible) + }; +} diff --git a/src/features/menus/routes.js b/src/features/menus/routes.js new file mode 100644 index 0000000..12c04af --- /dev/null +++ b/src/features/menus/routes.js @@ -0,0 +1,12 @@ +import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; + +export const menusRoutes = [ + { + label: "菜单权限", + loader: () => import("./pages/MenuManagementPage.jsx").then((module) => module.MenuManagementPage), + menuCode: MENU_CODES.systemMenus, + pageKey: "menus", + path: "/system/menus", + permission: PERMISSIONS.menuView + } +]; diff --git a/src/features/menus/schema.ts b/src/features/menus/schema.ts new file mode 100644 index 0000000..a710612 --- /dev/null +++ b/src/features/menus/schema.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +const optionalText = (max: number, message: string) => z.string().trim().max(max, message).optional().default(""); + +export const menuFormSchema = z.object({ + code: z.string().trim().min(1, "请输入菜单编码").max(80, "菜单编码不能超过 80 个字符"), + icon: optionalText(40, "图标不能超过 40 个字符"), + label: z.string().trim().min(1, "请输入菜单名称").max(60, "菜单名称不能超过 60 个字符"), + parentId: z + .union([z.literal(""), z.coerce.number().int().positive(), z.null()]) + .optional() + .transform((value) => (value === "" || value === undefined ? null : value)), + path: optionalText(120, "路径不能超过 120 个字符"), + permissionCode: optionalText(120, "权限码不能超过 120 个字符"), + sort: z.coerce.number().int().min(0, "排序不能小于 0").default(0), + visible: z.boolean().default(true) +}); + +export const permissionFormSchema = z.object({ + code: z.string().trim().min(1, "请输入权限码").max(120, "权限码不能超过 120 个字符"), + description: optionalText(200, "描述不能超过 200 个字符"), + kind: z.enum(["api", "button", "menu"]), + name: z.string().trim().min(1, "请输入权限名称").max(60, "权限名称不能超过 60 个字符") +}); + +export type MenuForm = z.infer; +export type PermissionForm = z.infer; diff --git a/src/features/notifications/api.ts b/src/features/notifications/api.ts new file mode 100644 index 0000000..f83cba5 --- /dev/null +++ b/src/features/notifications/api.ts @@ -0,0 +1,28 @@ +import { apiRequest } from "@/shared/api/request"; +import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import type { EntityId, NotificationDto, NotificationListDto } from "@/shared/api/types"; + +export function listNotifications(): Promise { + return apiRequest(apiEndpointPath(API_OPERATIONS.listNotifications)); +} + +export function markNotificationRead(id: EntityId): Promise { + const endpoint = API_ENDPOINTS.markNotificationRead; + return apiRequest>(apiEndpointPath(API_OPERATIONS.markNotificationRead, { id }), { + body: {}, + method: endpoint.method + }); +} + +export function markAllNotificationsRead(): Promise { + const endpoint = API_ENDPOINTS.markAllNotificationsRead; + return apiRequest>(apiEndpointPath(API_OPERATIONS.markAllNotificationsRead), { + body: {}, + method: endpoint.method + }); +} + +export function deleteNotification(id: EntityId): Promise { + const endpoint = API_ENDPOINTS.deleteNotification; + return apiRequest(apiEndpointPath(API_OPERATIONS.deleteNotification, { id }), { method: endpoint.method }); +} diff --git a/src/features/notifications/components/NotificationList.jsx b/src/features/notifications/components/NotificationList.jsx new file mode 100644 index 0000000..c625ba2 --- /dev/null +++ b/src/features/notifications/components/NotificationList.jsx @@ -0,0 +1,46 @@ +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; +import DoneAllOutlined from "@mui/icons-material/DoneAllOutlined"; +import { Button } from "@/shared/ui/Button.jsx"; +import { Card } from "@/shared/ui/Card.jsx"; + +export function NotificationList({ abilities, items, onDelete, onRead }) { + return ( + +
+ {(items || []).map((item) => ( +
+
+
{item.title}
+
{item.content}
+
{formatDate(item.createdAt)}
+
+
+ {!item.readAt && abilities.canRead ? ( + + ) : ( + {item.readAt ? "已读" : "未读"} + )} + {abilities.canDelete ? ( + + ) : null} +
+
+ ))} + {!items?.length ?
暂无通知
: null} +
+
+ ); +} + +function formatDate(value) { + if (!value) { + return "-"; + } + return new Date(value).toLocaleString("zh-CN", { hour12: false }); +} diff --git a/src/features/notifications/components/NotificationToolbar.jsx b/src/features/notifications/components/NotificationToolbar.jsx new file mode 100644 index 0000000..9777085 --- /dev/null +++ b/src/features/notifications/components/NotificationToolbar.jsx @@ -0,0 +1,16 @@ +import DoneAllOutlined from "@mui/icons-material/DoneAllOutlined"; +import { Button } from "@/shared/ui/Button.jsx"; +import { PageHead } from "@/shared/ui/PageHead.jsx"; + +export function NotificationToolbar({ canReadAll, onReadAll, unread }) { + return ( + + {canReadAll ? ( + + ) : null} + + ); +} diff --git a/src/features/notifications/hooks/useNotificationsPage.js b/src/features/notifications/hooks/useNotificationsPage.js new file mode 100644 index 0000000..c37697e --- /dev/null +++ b/src/features/notifications/hooks/useNotificationsPage.js @@ -0,0 +1,62 @@ +import { useCallback } from "react"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { + deleteNotification, + listNotifications, + markAllNotificationsRead, + markNotificationRead +} from "@/features/notifications/api"; +import { useNotificationAbilities } from "@/features/notifications/permissions.js"; + +const emptyData = { items: [], unread: 0 }; + +export function useNotificationsPage() { + const abilities = useNotificationAbilities(); + const confirm = useConfirm(); + const { showToast } = useToast(); + + const queryFn = useCallback(() => { + return listNotifications(); + }, []); + const { data, error, loading, reload } = useAdminQuery(queryFn, { + errorMessage: "加载通知失败", + initialData: emptyData, + keepPreviousData: true, + queryKey: ["notifications"] + }); + + const read = async (id) => { + await markNotificationRead(id); + showToast("通知已标记为已读", "success"); + reload(); + }; + + const readAll = async () => { + await markAllNotificationsRead(); + showToast("通知已全部标记为已读", "success"); + reload(); + }; + + const remove = async (item) => { + const ok = await confirm({ confirmText: "删除", message: item.title, title: "删除通知", tone: "danger" }); + if (!ok) { + return; + } + await deleteNotification(item.id); + showToast("通知已删除", "success"); + reload(); + }; + + return { + abilities, + data: data || emptyData, + error, + loading, + read, + readAll, + reload, + remove + }; +} diff --git a/src/features/notifications/notifications.css b/src/features/notifications/notifications.css new file mode 100644 index 0000000..5e740be --- /dev/null +++ b/src/features/notifications/notifications.css @@ -0,0 +1,25 @@ +.notification-list { + display: grid; +} + +.notification-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-3); + min-height: 86px; + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--row-border); +} + +.notification-title { + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 700; +} + +.notification-content { + margin: var(--space-2) 0; + color: var(--text-secondary); + font-size: var(--admin-font-size); +} diff --git a/src/features/notifications/pages/NotificationsPage.jsx b/src/features/notifications/pages/NotificationsPage.jsx new file mode 100644 index 0000000..7ee1927 --- /dev/null +++ b/src/features/notifications/pages/NotificationsPage.jsx @@ -0,0 +1,17 @@ +import { DataState } from "@/shared/ui/DataState.jsx"; +import { NotificationList } from "@/features/notifications/components/NotificationList.jsx"; +import { NotificationToolbar } from "@/features/notifications/components/NotificationToolbar.jsx"; +import { useNotificationsPage } from "@/features/notifications/hooks/useNotificationsPage.js"; + +export function NotificationsPage() { + const page = useNotificationsPage(); + + return ( + <> + + + + + + ); +} diff --git a/src/features/notifications/permissions.js b/src/features/notifications/permissions.js new file mode 100644 index 0000000..58316ef --- /dev/null +++ b/src/features/notifications/permissions.js @@ -0,0 +1,12 @@ +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { PERMISSIONS } from "@/app/permissions"; + +export function useNotificationAbilities() { + const { can } = useAuth(); + + return { + canDelete: can(PERMISSIONS.notificationDelete), + canRead: can(PERMISSIONS.notificationRead), + canReadAll: can(PERMISSIONS.notificationReadAll) + }; +} diff --git a/src/features/notifications/routes.js b/src/features/notifications/routes.js new file mode 100644 index 0000000..e79c24f --- /dev/null +++ b/src/features/notifications/routes.js @@ -0,0 +1,12 @@ +import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; + +export const notificationsRoutes = [ + { + label: "通知中心", + loader: () => import("./pages/NotificationsPage.jsx").then((module) => module.NotificationsPage), + menuCode: MENU_CODES.notifications, + pageKey: "notifications", + path: "/notifications", + permission: PERMISSIONS.notificationView + } +]; diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts new file mode 100644 index 0000000..ff286f7 --- /dev/null +++ b/src/features/resources/api.test.ts @@ -0,0 +1,204 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { setAccessToken } from "@/shared/api/request"; +import { + createGift, + createResource, + createResourceGroup, + disableGift, + disableResource, + disableResourceGroup, + enableGift, + enableResource, + enableResourceGroup, + grantResource, + grantResourceGroup, + listResourceGrants, + listGifts, + listResources, + updateGift, + updateResource, + updateResourceGroup, +} from "./api"; + +afterEach(() => { + setAccessToken(""); + vi.unstubAllGlobals(); +}); + +test("resource APIs use generated admin paths", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 10, total: 0 } })), + ), + ); + + await listResources({ page: 1, page_size: 10, resource_type: "gift", status: "active" }); + await createResource({ + amount: 0, + assetUrl: "https://media.haiyihy.com/resource/material.png", + name: "Rose", + previewUrl: "https://media.haiyihy.com/resource/cover.png", + resourceCode: "gift_rose_test", + resourceType: "gift", + sortOrder: 0, + status: "active", + }); + await updateResource(11, { + amount: 0, + assetUrl: "https://media.haiyihy.com/resource/material-updated.png", + name: "Rose Updated", + previewUrl: "https://media.haiyihy.com/resource/cover-updated.png", + resourceCode: "gift_rose_test", + resourceType: "gift", + sortOrder: 0, + status: "active", + }); + await listGifts({ page: 1, page_size: 10, region_id: 1001 }); + await createGift({ + coinPrice: 10, + effectiveAtMs: 1777766400000, + giftId: "rose", + giftPointAmount: 1, + heatValue: 1, + name: "Rose", + presentationJson: "{}", + priceVersion: "default", + regionIds: [0], + resourceId: 11, + sortOrder: 0, + status: "active", + }); + await enableResource(11); + await disableResource(11); + await createResourceGroup({ + description: "", + groupCode: "starter_pack_test", + items: [ + { + durationDays: 7, + itemType: "resource", + resourceId: 11, + sortOrder: 0, + }, + { + amount: 1000, + assetType: "DIAMOND", + itemType: "wallet_asset", + sortOrder: 1, + }, + ], + name: "Starter Pack", + sortOrder: 0, + status: "active", + }); + await updateResourceGroup(22, { + description: "updated", + groupCode: "starter_pack_test", + items: [], + name: "Starter Pack Updated", + sortOrder: 1, + status: "disabled", + }); + await enableResourceGroup(22); + await disableResourceGroup(22); + await updateGift("rose", { + coinPrice: 10, + effectiveAtMs: 1777766400000, + giftId: "rose", + giftPointAmount: 1, + heatValue: 1, + name: "Rose", + presentationJson: "{}", + priceVersion: "default", + regionIds: [0], + resourceId: 11, + sortOrder: 0, + status: "active", + }); + await enableGift("rose"); + await disableGift("rose"); + await listResourceGrants({ page: 1, page_size: 10, target_user_id: 1001, status: "succeeded" }); + await grantResource({ + commandId: "resource-grant-test", + durationMs: 0, + quantity: 1, + reason: "manual", + resourceId: 11, + targetUserId: 1001, + }); + await grantResourceGroup({ + commandId: "resource-group-grant-test", + groupId: 22, + reason: "manual", + targetUserId: 1001, + }); + + const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0]; + const [createUrl, createInit] = vi.mocked(fetch).mock.calls[1]; + const [updateUrl, updateInit] = vi.mocked(fetch).mock.calls[2]; + const [giftListUrl, giftListInit] = vi.mocked(fetch).mock.calls[3]; + const [createGiftUrl, createGiftInit] = vi.mocked(fetch).mock.calls[4]; + const [enableUrl, enableInit] = vi.mocked(fetch).mock.calls[5]; + const [disableUrl, disableInit] = vi.mocked(fetch).mock.calls[6]; + const [createGroupUrl, createGroupInit] = vi.mocked(fetch).mock.calls[7]; + const [updateGroupUrl, updateGroupInit] = vi.mocked(fetch).mock.calls[8]; + const [enableGroupUrl, enableGroupInit] = vi.mocked(fetch).mock.calls[9]; + const [disableGroupUrl, disableGroupInit] = vi.mocked(fetch).mock.calls[10]; + const [updateGiftUrl, updateGiftInit] = vi.mocked(fetch).mock.calls[11]; + const [enableGiftUrl, enableGiftInit] = vi.mocked(fetch).mock.calls[12]; + const [disableGiftUrl, disableGiftInit] = vi.mocked(fetch).mock.calls[13]; + const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[14]; + const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[15]; + const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[16]; + + expect(String(listUrl)).toContain("/api/v1/admin/resources?"); + expect(String(listUrl)).toContain("resource_type=gift"); + expect(String(listUrl)).toContain("status=active"); + expect(listInit?.method).toBe("GET"); + expect(String(createUrl)).toContain("/api/v1/admin/resources"); + expect(createInit?.method).toBe("POST"); + expect(JSON.parse(String(createInit?.body))).toMatchObject({ resourceCode: "gift_rose_test", status: "active" }); + expect(String(updateUrl)).toContain("/api/v1/admin/resources/11"); + expect(updateInit?.method).toBe("PUT"); + expect(String(giftListUrl)).toContain("/api/v1/admin/gifts?"); + expect(String(giftListUrl)).toContain("region_id=1001"); + expect(giftListInit?.method).toBe("GET"); + expect(String(createGiftUrl)).toContain("/api/v1/admin/gifts"); + expect(createGiftInit?.method).toBe("POST"); + expect(JSON.parse(String(createGiftInit?.body))).toMatchObject({ giftId: "rose", resourceId: 11 }); + expect(String(enableUrl)).toContain("/api/v1/admin/resources/11/enable"); + expect(enableInit?.method).toBe("POST"); + expect(String(disableUrl)).toContain("/api/v1/admin/resources/11/disable"); + expect(disableInit?.method).toBe("POST"); + expect(String(createGroupUrl)).toContain("/api/v1/admin/resource-groups"); + expect(createGroupInit?.method).toBe("POST"); + expect(JSON.parse(String(createGroupInit?.body))).toMatchObject({ + groupCode: "starter_pack_test", + items: [ + { durationDays: 7, itemType: "resource" }, + { amount: 1000, assetType: "DIAMOND" }, + ], + }); + expect(String(updateGroupUrl)).toContain("/api/v1/admin/resource-groups/22"); + expect(updateGroupInit?.method).toBe("PUT"); + expect(String(enableGroupUrl)).toContain("/api/v1/admin/resource-groups/22/enable"); + expect(enableGroupInit?.method).toBe("POST"); + expect(String(disableGroupUrl)).toContain("/api/v1/admin/resource-groups/22/disable"); + expect(disableGroupInit?.method).toBe("POST"); + expect(String(updateGiftUrl)).toContain("/api/v1/admin/gifts/rose"); + expect(updateGiftInit?.method).toBe("PUT"); + expect(String(enableGiftUrl)).toContain("/api/v1/admin/gifts/rose/enable"); + expect(enableGiftInit?.method).toBe("POST"); + expect(String(disableGiftUrl)).toContain("/api/v1/admin/gifts/rose/disable"); + expect(disableGiftInit?.method).toBe("POST"); + expect(String(grantListUrl)).toContain("/api/v1/admin/resource-grants?"); + expect(String(grantListUrl)).toContain("target_user_id=1001"); + expect(grantListInit?.method).toBe("GET"); + expect(String(grantResourceUrl)).toContain("/api/v1/admin/resource-grants/resource"); + expect(grantResourceInit?.method).toBe("POST"); + expect(JSON.parse(String(grantResourceInit?.body))).toMatchObject({ resourceId: 11, targetUserId: 1001 }); + expect(String(grantGroupUrl)).toContain("/api/v1/admin/resource-grants/group"); + expect(grantGroupInit?.method).toBe("POST"); + expect(JSON.parse(String(grantGroupInit?.body))).toMatchObject({ groupId: 22, targetUserId: 1001 }); +}); diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts new file mode 100644 index 0000000..840c2a7 --- /dev/null +++ b/src/features/resources/api.ts @@ -0,0 +1,300 @@ +import { apiRequest } from "@/shared/api/request"; +import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import type { ApiPage, EntityId, PageQuery } from "@/shared/api/types"; + +export interface ResourceDto { + appCode?: string; + resourceId: number; + resourceCode?: string; + resourceType?: string; + name?: string; + status?: string; + grantable?: boolean; + grantStrategy?: string; + walletAssetType?: string; + walletAssetAmount?: number; + usageScopes?: string[]; + assetUrl?: string; + previewUrl?: string; + animationUrl?: string; + metadataJson?: string; + sortOrder?: number; + createdAtMs?: number; + updatedAtMs?: number; +} + +export interface ResourcePayload { + amount: number; + assetUrl: string; + name: string; + previewUrl: string; + resourceCode: string; + resourceType: string; + sortOrder: number; + status: string; +} + +export interface ResourceGroupItemDto { + groupItemId: number; + itemType?: string; + resourceId: number; + resource?: ResourceDto; + walletAssetType?: string; + walletAssetAmount?: number; + quantity?: number; + durationMs?: number; + sortOrder?: number; +} + +export interface ResourceGroupDto { + appCode?: string; + groupId: number; + groupCode?: string; + name?: string; + status?: string; + description?: string; + sortOrder?: number; + items?: ResourceGroupItemDto[]; + createdAtMs?: number; + updatedAtMs?: number; +} + +export interface GiftDto { + appCode?: string; + giftId: string; + resourceId: number; + resource?: ResourceDto; + status?: string; + name?: string; + sortOrder?: number; + presentationJson?: string; + priceVersion?: string; + coinPrice?: number; + giftPointAmount?: number; + heatValue?: number; + createdAtMs?: number; + updatedAtMs?: number; + regionIds?: number[]; +} + +export interface GiftPayload { + giftId: string; + resourceId: number; + status: string; + name: string; + sortOrder: number; + presentationJson: string; + priceVersion: string; + coinPrice: number; + giftPointAmount: number; + heatValue: number; + effectiveAtMs: number; + regionIds: number[]; +} + +export interface ResourceGrantItemDto { + createdAtMs?: number; + durationMs?: number; + entitlementId?: string; + grantId?: string; + grantItemId?: number; + quantity?: number; + resourceId?: number; + resourceSnapshotJson?: string; + resultType?: string; + walletTransactionId?: string; +} + +export interface ResourceGrantDto { + appCode?: string; + commandId?: string; + createdAtMs?: number; + grantId: string; + grantSource?: string; + grantSubjectId?: string; + grantSubjectType?: string; + items?: ResourceGrantItemDto[]; + operatorUserId?: number; + reason?: string; + status?: string; + targetUserId?: number; + updatedAtMs?: number; +} + +export interface GrantResourcePayload { + commandId: string; + durationMs: number; + quantity: number; + reason: string; + resourceId: number; + targetUserId: number; +} + +export interface GrantResourceGroupPayload { + commandId: string; + groupId: number; + reason: string; + targetUserId: number; +} + +export interface ResourceGroupItemPayload { + itemType: string; + amount?: number; + assetType?: string; + durationDays?: number; + resourceId?: number; + sortOrder: number; +} + +export interface ResourceGroupPayload { + groupCode: string; + name: string; + status: string; + description: string; + sortOrder: number; + items: ResourceGroupItemPayload[]; +} + +export function listResources(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listResources; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listResources), { + method: endpoint.method, + query, + }); +} + +export function createResource(payload: ResourcePayload): Promise { + const endpoint = API_ENDPOINTS.createResource; + return apiRequest(apiEndpointPath(API_OPERATIONS.createResource), { + body: payload, + method: endpoint.method, + }); +} + +export function updateResource(resourceId: EntityId, payload: ResourcePayload): Promise { + const endpoint = API_ENDPOINTS.updateResource; + return apiRequest( + apiEndpointPath(API_OPERATIONS.updateResource, { resource_id: resourceId }), + { + body: payload, + method: endpoint.method, + }, + ); +} + +export function enableResource(resourceId: EntityId): Promise { + const endpoint = API_ENDPOINTS.enableResource; + return apiRequest(apiEndpointPath(API_OPERATIONS.enableResource, { resource_id: resourceId }), { + method: endpoint.method, + }); +} + +export function disableResource(resourceId: EntityId): Promise { + const endpoint = API_ENDPOINTS.disableResource; + return apiRequest(apiEndpointPath(API_OPERATIONS.disableResource, { resource_id: resourceId }), { + method: endpoint.method, + }); +} + +export function listResourceGroups(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listResourceGroups; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listResourceGroups), { + method: endpoint.method, + query, + }); +} + +export function createResourceGroup(payload: ResourceGroupPayload): Promise { + const endpoint = API_ENDPOINTS.createResourceGroup; + return apiRequest(apiEndpointPath(API_OPERATIONS.createResourceGroup), { + body: payload, + method: endpoint.method, + }); +} + +export function updateResourceGroup(groupId: EntityId, payload: ResourceGroupPayload): Promise { + const endpoint = API_ENDPOINTS.updateResourceGroup; + return apiRequest( + apiEndpointPath(API_OPERATIONS.updateResourceGroup, { group_id: groupId }), + { + body: payload, + method: endpoint.method, + }, + ); +} + +export function enableResourceGroup(groupId: EntityId): Promise { + const endpoint = API_ENDPOINTS.enableResourceGroup; + return apiRequest(apiEndpointPath(API_OPERATIONS.enableResourceGroup, { group_id: groupId }), { + method: endpoint.method, + }); +} + +export function disableResourceGroup(groupId: EntityId): Promise { + const endpoint = API_ENDPOINTS.disableResourceGroup; + return apiRequest(apiEndpointPath(API_OPERATIONS.disableResourceGroup, { group_id: groupId }), { + method: endpoint.method, + }); +} + +export function listGifts(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listGifts; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listGifts), { + method: endpoint.method, + query, + }); +} + +export function createGift(payload: GiftPayload): Promise { + const endpoint = API_ENDPOINTS.createGift; + return apiRequest(apiEndpointPath(API_OPERATIONS.createGift), { + body: payload, + method: endpoint.method, + }); +} + +export function updateGift(giftId: string, payload: GiftPayload): Promise { + const endpoint = API_ENDPOINTS.updateGift; + return apiRequest(apiEndpointPath(API_OPERATIONS.updateGift, { gift_id: giftId }), { + body: payload, + method: endpoint.method, + }); +} + +export function enableGift(giftId: string): Promise { + const endpoint = API_ENDPOINTS.enableGift; + return apiRequest(apiEndpointPath(API_OPERATIONS.enableGift, { gift_id: giftId }), { + method: endpoint.method, + }); +} + +export function disableGift(giftId: string): Promise { + const endpoint = API_ENDPOINTS.disableGift; + return apiRequest(apiEndpointPath(API_OPERATIONS.disableGift, { gift_id: giftId }), { + method: endpoint.method, + }); +} + +export function listResourceGrants(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listResourceGrants; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listResourceGrants), { + method: endpoint.method, + query, + }); +} + +export function grantResource(payload: GrantResourcePayload): Promise { + const endpoint = API_ENDPOINTS.grantResource; + return apiRequest(apiEndpointPath(API_OPERATIONS.grantResource), { + body: payload, + method: endpoint.method, + }); +} + +export function grantResourceGroup(payload: GrantResourceGroupPayload): Promise { + const endpoint = API_ENDPOINTS.grantResourceGroup; + return apiRequest(apiEndpointPath(API_OPERATIONS.grantResourceGroup), { + body: payload, + method: endpoint.method, + }); +} diff --git a/src/features/resources/constants.js b/src/features/resources/constants.js new file mode 100644 index 0000000..9b841a4 --- /dev/null +++ b/src/features/resources/constants.js @@ -0,0 +1,52 @@ +export const resourceStatusFilters = [ + ["", "全部状态"], + ["active", "启用"], + ["disabled", "禁用"], +]; + +export const resourceStatusLabels = { + active: "启用", + disabled: "禁用", +}; + +export const resourceGrantStatusFilters = [ + ["", "全部状态"], + ["succeeded", "成功"], +]; + +export const resourceGrantStatusLabels = { + succeeded: "成功", +}; + +export const resourceGrantSubjectLabels = { + resource: "资源", + resource_group: "资源组", +}; + +export const resourceTypeFilters = [ + ["", "全部类型"], + ["avatar_frame", "头像框"], + ["coin", "金币"], + ["vehicle", "座驾"], + ["chat_bubble", "气泡"], + ["badge", "徽章"], + ["floating_screen", "飘屏"], + ["gift", "礼物"], +]; + +export const resourceTypeLabels = Object.fromEntries(resourceTypeFilters.filter(([value]) => value)); + +export const resourceGroupAssetOptions = [ + ["COIN", "金币"], + ["DIAMOND", "钻石"], +]; + +export const resourceGroupAssetLabels = Object.fromEntries(resourceGroupAssetOptions); + +export const grantStrategyLabels = { + extend_expiry: "延长有效期", + increase_quantity: "增加数量", + new_entitlement: "新增权益", + set_active_flag: "激活标记", + wallet_credit: "钱包入账", +}; diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js new file mode 100644 index 0000000..13081b2 --- /dev/null +++ b/src/features/resources/hooks/useResourcePages.js @@ -0,0 +1,811 @@ +import { useEffect, useMemo, useState } from "react"; +import { parseForm } from "@/shared/forms/validation"; +import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; +import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { + createGift, + createResource, + createResourceGroup, + disableGift, + disableResource, + disableResourceGroup, + enableGift, + enableResource, + enableResourceGroup, + grantResource, + grantResourceGroup, + listGifts, + listResourceGrants, + listResourceGroups, + listResources, + updateGift, + updateResource, + updateResourceGroup, +} from "@/features/resources/api"; +import { useResourceAbilities } from "@/features/resources/permissions.js"; +import { + giftFormSchema, + resourceFormSchema, + resourceGrantFormSchema, + resourceGroupCreateFormSchema, +} from "@/features/resources/schema.js"; + +const pageSize = 10; +const dayMillis = 24 * 60 * 60 * 1000; +const emptyData = { items: [], page: 1, pageSize, total: 0 }; +const emptyResourceForm = (resource = {}) => ({ + assetUrl: resource.assetUrl || "", + enabled: resource.status ? resource.status === "active" : true, + name: resource.name || "", + previewUrl: resource.previewUrl || "", + resourceCode: resource.resourceCode || "", + resourceType: resource.resourceType || "avatar_frame", + walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "", +}); +const emptyResourceGroupForm = (group = {}) => ({ + description: group.description || "", + enabled: group.status ? group.status === "active" : true, + groupCode: group.groupCode || "", + items: group.items?.length ? group.items.map(groupItemToForm) : [emptyGroupResourceItem()], + name: group.name || "", + sortOrder: group.sortOrder === 0 || group.sortOrder ? String(group.sortOrder) : "0", +}); +const emptyGroupResourceItem = () => ({ + durationDays: "1", + itemType: "resource", + resourceId: "", + sortOrder: "0", + walletAssetAmount: "", + walletAssetType: "", +}); +const emptyGroupWalletAssetItem = (walletAssetType) => ({ + durationDays: "", + itemType: "wallet_asset", + resourceId: "", + sortOrder: "0", + walletAssetAmount: "", + walletAssetType, +}); +const emptyGiftForm = (gift = {}) => ({ + coinPrice: gift.coinPrice === 0 || gift.coinPrice ? String(gift.coinPrice) : "", + enabled: gift.status ? gift.status === "active" : true, + giftId: gift.giftId || "", + giftPointAmount: gift.giftPointAmount === 0 || gift.giftPointAmount ? String(gift.giftPointAmount) : "0", + heatValue: gift.heatValue === 0 || gift.heatValue ? String(gift.heatValue) : "0", + name: gift.name || "", + presentationJson: gift.presentationJson || "{}", + priceVersion: gift.priceVersion || "default", + regionIds: (gift.regionIds?.length ? gift.regionIds : [0]).map(String), + resourceId: gift.resourceId ? String(gift.resourceId) : "", + sortOrder: gift.sortOrder === 0 || gift.sortOrder ? String(gift.sortOrder) : "0", +}); +const emptyGrantForm = () => ({ + durationDays: "0", + groupId: "", + quantity: "1", + reason: "", + resourceId: "", + subjectType: "resource", + targetUserId: "", +}); + +function groupItemToForm(item) { + const sortOrder = item.sortOrder === 0 || item.sortOrder ? String(item.sortOrder) : "0"; + if (item.itemType === "wallet_asset") { + return { + durationDays: "", + itemType: "wallet_asset", + resourceId: "", + sortOrder, + walletAssetAmount: + item.walletAssetAmount === 0 || item.walletAssetAmount ? String(item.walletAssetAmount) : "", + walletAssetType: item.walletAssetType || "", + }; + } + + return { + durationDays: item.durationMs ? formatDurationDays(item.durationMs) : "1", + itemType: "resource", + resourceId: item.resourceId ? String(item.resourceId) : "", + sortOrder, + walletAssetAmount: "", + walletAssetType: "", + }; +} + +export function useResourceListPage() { + const abilities = useResourceAbilities(); + const { showToast } = useToast(); + const [query, setQuery] = useState(""); + const [status, setStatus] = useState(""); + const [resourceType, setResourceType] = useState(""); + const [page, setPage] = useState(1); + const [activeAction, setActiveAction] = useState(""); + const [form, setForm] = useState(emptyResourceForm); + const [selectedResource, setSelectedResource] = useState(null); + const [loadingAction, setLoadingAction] = useState(""); + const filters = useMemo( + () => ({ + keyword: query, + resource_type: resourceType, + status, + }), + [query, resourceType, status], + ); + const result = usePaginatedQuery({ + errorMessage: "加载资源列表失败", + fetcher: listResources, + filters, + page, + pageSize, + queryKey: ["resources", filters, page], + }); + + const openCreateResource = () => { + setForm(emptyResourceForm()); + setSelectedResource(null); + setActiveAction("create"); + }; + + const openEditResource = (resource) => { + if (!resource?.resourceId) { + return; + } + setForm(emptyResourceForm(resource)); + setSelectedResource(resource); + setActiveAction("edit"); + }; + + const closeAction = () => { + setActiveAction(""); + setSelectedResource(null); + }; + + const submitResource = async (event) => { + event.preventDefault(); + const editing = activeAction === "edit"; + await runAction( + editing ? "resource-edit" : "resource-create", + editing ? "资源已更新" : "资源已创建", + async () => { + const payload = buildResourcePayload(parseForm(resourceFormSchema, form)); + if (editing) { + await updateResource(selectedResource.resourceId, payload); + } else { + await createResource(payload); + } + setForm(emptyResourceForm()); + closeAction(); + await result.reload(); + }, + ); + }; + + const toggleResource = async (resource, nextEnabled = resource.status !== "active") => { + if (!abilities.canUpdate || !resource?.resourceId) { + return; + } + const nextStatus = nextEnabled ? "active" : "disabled"; + if ((resource.status === "active") === nextEnabled) { + return; + } + await runAction( + `resource-status-${resource.resourceId}`, + nextEnabled ? "资源已启用" : "资源已禁用", + async () => { + if (nextStatus === "active") { + await enableResource(resource.resourceId); + } else { + await disableResource(resource.resourceId); + } + await result.reload(); + }, + ); + }; + + const runAction = async (action, successMessage, submitter) => { + setLoadingAction(action); + try { + await submitter(); + showToast(successMessage, "success"); + } catch (err) { + showToast(err.message || "操作失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + return { + ...sharedPageState({ page, query, result, setPage, setQuery }), + abilities, + activeAction, + changeResourceType: resetSetter(setResourceType, setPage), + changeStatus: resetSetter(setStatus, setPage), + closeAction, + form, + loadingAction, + openCreateResource, + openEditResource, + resourceType, + selectedResource, + setForm, + status, + submitResource, + toggleResource, + }; +} + +export function useResourceGroupListPage() { + const abilities = useResourceAbilities(); + const { showToast } = useToast(); + const [query, setQuery] = useState(""); + const [status, setStatus] = useState(""); + const [page, setPage] = useState(1); + const [activeAction, setActiveAction] = useState(""); + const [form, setForm] = useState(emptyResourceGroupForm); + const [selectedGroup, setSelectedGroup] = useState(null); + const [loadingAction, setLoadingAction] = useState(""); + const [resourceOptions, setResourceOptions] = useState([]); + const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false); + const filters = useMemo( + () => ({ + keyword: query, + status, + }), + [query, status], + ); + const result = usePaginatedQuery({ + errorMessage: "加载资源组列表失败", + fetcher: listResourceGroups, + filters, + page, + pageSize, + queryKey: ["resource-groups", filters, page], + }); + + useEffect(() => { + if (activeAction !== "create" && activeAction !== "edit") { + return undefined; + } + let ignore = false; + setResourceOptionsLoading(true); + listResources({ page: 1, page_size: 100, status: "active" }) + .then((data) => { + if (!ignore) { + setResourceOptions(toGroupResourceOptions(data.items || [], selectedGroup)); + } + }) + .catch((err) => { + if (!ignore) { + showToast(err.message || "加载资源选项失败", "error"); + } + }) + .finally(() => { + if (!ignore) { + setResourceOptionsLoading(false); + } + }); + return () => { + ignore = true; + }; + }, [activeAction, selectedGroup, showToast]); + + const openCreateGroup = () => { + setForm(emptyResourceGroupForm()); + setSelectedGroup(null); + setActiveAction("create"); + }; + + const openEditGroup = (group) => { + if (!group?.groupId) { + return; + } + setForm(emptyResourceGroupForm(group)); + setSelectedGroup(group); + setActiveAction("edit"); + }; + + const closeAction = () => { + setActiveAction(""); + setSelectedGroup(null); + }; + + const addResourceItem = () => { + setForm((current) => ({ ...current, items: [...current.items, emptyGroupResourceItem()] })); + }; + + const addWalletAssetItem = (walletAssetType) => { + setForm((current) => ({ ...current, items: [...current.items, emptyGroupWalletAssetItem(walletAssetType)] })); + }; + + const updateGroupItem = (index, patch) => { + setForm((current) => ({ + ...current, + items: current.items.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)), + })); + }; + + const removeGroupItem = (index) => { + setForm((current) => ({ + ...current, + items: current.items.filter((_, itemIndex) => itemIndex !== index), + })); + }; + + const submitGroup = async (event) => { + event.preventDefault(); + const editing = activeAction === "edit"; + const action = editing ? "group-edit" : "group-create"; + const successMessage = editing ? "资源组已更新" : "资源组已创建"; + setLoadingAction(action); + try { + const payload = buildResourceGroupPayload(parseForm(resourceGroupCreateFormSchema, form)); + if (editing) { + await updateResourceGroup(selectedGroup.groupId, payload); + } else { + await createResourceGroup(payload); + } + showToast(successMessage, "success"); + setForm(emptyResourceGroupForm()); + closeAction(); + await result.reload(); + } catch (err) { + showToast(err.message || "操作失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + const toggleGroup = async (group, nextEnabled = group.status !== "active") => { + if (!abilities.canUpdateGroup || !group?.groupId) { + return; + } + if ((group.status === "active") === nextEnabled) { + return; + } + setLoadingAction(`group-status-${group.groupId}`); + try { + if (nextEnabled) { + await enableResourceGroup(group.groupId); + } else { + await disableResourceGroup(group.groupId); + } + showToast(nextEnabled ? "资源组已启用" : "资源组已禁用", "success"); + await result.reload(); + } catch (err) { + showToast(err.message || "操作失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + return { + ...sharedPageState({ page, query, result, setPage, setQuery }), + abilities, + activeAction, + addResourceItem, + addWalletAssetItem, + changeStatus: resetSetter(setStatus, setPage), + closeAction, + form, + loadingAction, + openCreateGroup, + openEditGroup, + removeGroupItem, + resourceOptions, + resourceOptionsLoading, + selectedGroup, + setForm, + status, + submitGroup, + toggleGroup, + updateGroupItem, + }; +} + +export function useGiftListPage() { + const abilities = useResourceAbilities(); + const { showToast } = useToast(); + const [query, setQuery] = useState(""); + const [status, setStatus] = useState(""); + const [regionId, setRegionId] = useState(""); + const [page, setPage] = useState(1); + const [activeAction, setActiveAction] = useState(""); + const [form, setForm] = useState(emptyGiftForm); + const [selectedGift, setSelectedGift] = useState(null); + const [loadingAction, setLoadingAction] = useState(""); + const [resourceOptions, setResourceOptions] = useState([]); + const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false); + const { loadingRegions, regionOptions } = useRegionOptions(); + const filters = useMemo( + () => ({ + keyword: query, + region_id: regionId, + status, + }), + [query, regionId, status], + ); + const result = usePaginatedQuery({ + errorMessage: "加载礼物列表失败", + fetcher: listGifts, + filters, + page, + pageSize, + queryKey: ["gifts", filters, page], + }); + + useEffect(() => { + if (activeAction !== "create" && activeAction !== "edit") { + return undefined; + } + let ignore = false; + setResourceOptionsLoading(true); + listResources({ page: 1, page_size: 100, resource_type: "gift" }) + .then((data) => { + if (!ignore) { + setResourceOptions(toGiftResourceOptions(data.items || [], selectedGift)); + } + }) + .catch((err) => { + if (!ignore) { + showToast(err.message || "加载礼物资源失败", "error"); + } + }) + .finally(() => { + if (!ignore) { + setResourceOptionsLoading(false); + } + }); + return () => { + ignore = true; + }; + }, [activeAction, selectedGift, showToast]); + + const openCreateGift = () => { + setForm(emptyGiftForm()); + setSelectedGift(null); + setActiveAction("create"); + }; + + const openEditGift = (gift) => { + if (!gift?.giftId) { + return; + } + setForm(emptyGiftForm(gift)); + setSelectedGift(gift); + setActiveAction("edit"); + }; + + const closeAction = () => { + setActiveAction(""); + setSelectedGift(null); + }; + + const submitGift = async (event) => { + event.preventDefault(); + const editing = activeAction === "edit"; + if (editing && !selectedGift?.giftId) { + return; + } + setLoadingAction(editing ? "gift-edit" : "gift-create"); + try { + const payload = buildGiftPayload(parseForm(giftFormSchema, form)); + if (editing) { + await updateGift(selectedGift.giftId, payload); + } else { + await createGift(payload); + } + showToast(editing ? "礼物已更新" : "礼物已创建", "success"); + closeAction(); + await result.reload(); + } catch (err) { + showToast(err.message || "操作失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + const toggleGift = async (gift, nextEnabled = gift.status !== "active") => { + if (!abilities.canStatusGift || !gift?.giftId) { + return; + } + if ((gift.status === "active") === nextEnabled) { + return; + } + setLoadingAction(`gift-status-${gift.giftId}`); + try { + if (nextEnabled) { + await enableGift(gift.giftId); + } else { + await disableGift(gift.giftId); + } + showToast(nextEnabled ? "礼物已启用" : "礼物已禁用", "success"); + await result.reload(); + } catch (err) { + showToast(err.message || "操作失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + return { + ...sharedPageState({ page, query, result, setPage, setQuery }), + abilities, + activeAction, + changeRegionId: resetSetter(setRegionId, setPage), + changeStatus: resetSetter(setStatus, setPage), + closeAction, + form, + loadingAction, + loadingRegions, + openCreateGift, + openEditGift, + regionId, + regionOptions, + resourceOptions, + resourceOptionsLoading, + selectedGift, + setForm, + status, + submitGift, + toggleGift, + }; +} + +export function useResourceGrantListPage() { + const abilities = useResourceAbilities(); + const { showToast } = useToast(); + const [query, setQuery] = useState(""); + const [status, setStatus] = useState(""); + const [page, setPage] = useState(1); + const [activeAction, setActiveAction] = useState(""); + const [form, setForm] = useState(emptyGrantForm); + const [resourceOptions, setResourceOptions] = useState([]); + const [groupOptions, setGroupOptions] = useState([]); + const [optionsLoading, setOptionsLoading] = useState(false); + const [loadingAction, setLoadingAction] = useState(""); + const filters = useMemo( + () => ({ + status, + target_user_id: query.trim(), + }), + [query, status], + ); + const result = usePaginatedQuery({ + errorMessage: "加载资源赠送记录失败", + fetcher: listResourceGrants, + filters, + page, + pageSize, + queryKey: ["resource-grants", filters, page], + }); + + useEffect(() => { + if (activeAction !== "create") { + return undefined; + } + let ignore = false; + setOptionsLoading(true); + Promise.all([ + listResources({ page: 1, page_size: 100, status: "active" }), + listResourceGroups({ page: 1, page_size: 100, status: "active" }), + ]) + .then(([resources, groups]) => { + if (!ignore) { + setResourceOptions((resources.items || []).filter((resource) => resource.grantable !== false)); + setGroupOptions(groups.items || []); + } + }) + .catch((err) => { + if (!ignore) { + showToast(err.message || "加载资源选项失败", "error"); + } + }) + .finally(() => { + if (!ignore) { + setOptionsLoading(false); + } + }); + return () => { + ignore = true; + }; + }, [activeAction, showToast]); + + const openCreateGrant = () => { + setForm(emptyGrantForm()); + setActiveAction("create"); + }; + + const closeAction = () => { + setActiveAction(""); + }; + + const submitGrant = async (event) => { + event.preventDefault(); + if (!abilities.canCreateGrant) { + return; + } + setLoadingAction("grant-create"); + try { + const payload = buildGrantPayload(parseForm(resourceGrantFormSchema, form)); + if (payload.subjectType === "resource") { + await grantResource(payload.resource); + } else { + await grantResourceGroup(payload.group); + } + showToast("资源已赠送", "success"); + closeAction(); + setForm(emptyGrantForm()); + await result.reload(); + } catch (err) { + showToast(err.message || "操作失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + return { + ...sharedPageState({ page, query, result, setPage, setQuery }), + abilities, + activeAction, + changeStatus: resetSetter(setStatus, setPage), + closeAction, + form, + groupOptions, + loadingAction, + openCreateGrant, + optionsLoading, + resourceOptions, + setForm, + status, + submitGrant, + }; +} + +function sharedPageState({ page, query, result, setPage, setQuery }) { + return { + data: result.data || emptyData, + error: result.error, + loading: result.loading, + page, + query, + reload: result.reload, + setPage, + changeQuery: resetSetter(setQuery, setPage), + }; +} + +function resetSetter(setValue, setPage) { + return (value) => { + setValue(value); + setPage(1); + }; +} + +function buildResourcePayload(form) { + const resourceType = form.resourceType; + const isCoin = resourceType === "coin"; + return { + amount: isCoin ? Number(form.walletAssetAmount) : 0, + assetUrl: form.assetUrl.trim(), + name: form.name.trim(), + previewUrl: form.previewUrl.trim(), + resourceCode: form.resourceCode.trim(), + resourceType, + sortOrder: 0, + status: form.enabled ? "active" : "disabled", + }; +} + +function buildResourceGroupPayload(form) { + return { + description: (form.description || "").trim(), + groupCode: form.groupCode.trim(), + items: form.items.map((item, index) => { + const sortOrder = Number(item.sortOrder || index); + if (item.itemType === "wallet_asset") { + return { + amount: Number(item.walletAssetAmount), + assetType: item.walletAssetType, + itemType: "wallet_asset", + sortOrder: Number.isInteger(sortOrder) ? sortOrder : index, + }; + } + return { + durationDays: Number(item.durationDays), + itemType: "resource", + resourceId: Number(item.resourceId), + sortOrder: Number.isInteger(sortOrder) ? sortOrder : index, + }; + }), + name: form.name.trim(), + sortOrder: Number(form.sortOrder || 0), + status: form.enabled ? "active" : "disabled", + }; +} + +function buildGiftPayload(form) { + return { + coinPrice: Number(form.coinPrice), + effectiveAtMs: Date.now(), + giftId: form.giftId.trim(), + giftPointAmount: Number(form.giftPointAmount || 0), + heatValue: Number(form.heatValue || 0), + name: form.name.trim(), + presentationJson: form.presentationJson?.trim() || "{}", + priceVersion: form.priceVersion.trim(), + regionIds: form.regionIds.map(Number), + resourceId: Number(form.resourceId), + sortOrder: Number(form.sortOrder || 0), + status: form.enabled ? "active" : "disabled", + }; +} + +function buildGrantPayload(form) { + const targetUserId = Number(form.targetUserId); + const reason = form.reason.trim(); + const commandId = makeCommandId(form.subjectType === "resource" ? "resource-grant" : "resource-group-grant"); + if (form.subjectType === "resource") { + return { + resource: { + commandId, + durationMs: Math.round(Number(form.durationDays || 0) * dayMillis), + quantity: Number(form.quantity || 1), + reason, + resourceId: Number(form.resourceId), + targetUserId, + }, + subjectType: "resource", + }; + } + return { + group: { + commandId, + groupId: Number(form.groupId), + reason, + targetUserId, + }, + subjectType: "resource_group", + }; +} + +function makeCommandId(prefix) { + return `${prefix}-${Date.now()}`; +} + +function toGroupResourceOptions(items, selectedGroup) { + const selectedResources = (selectedGroup?.items || []) + .map((item) => item.resource) + .filter((resource) => resource?.resourceId); + return uniqueResources([...items, ...selectedResources]).filter( + (resource) => resource.resourceType !== "coin" && resource.status === "active" && resource.grantable !== false, + ); +} + +function toGiftResourceOptions(items, selectedGift) { + const selectedResource = selectedGift?.resource?.resourceId + ? [selectedGift.resource] + : selectedGift?.resourceId + ? [{ name: String(selectedGift.resourceId), resourceId: selectedGift.resourceId, resourceType: "gift" }] + : []; + return uniqueResources([...items, ...selectedResource]).filter( + (resource) => resource.resourceType === "gift" || resource.resourceId === selectedGift?.resourceId, + ); +} + +function uniqueResources(resources) { + const seen = new Set(); + return resources.filter((resource) => { + if (!resource?.resourceId || seen.has(resource.resourceId)) { + return false; + } + seen.add(resource.resourceId); + return true; + }); +} + +function formatDurationDays(durationMs) { + const days = Number(durationMs || 0) / dayMillis; + return Number.isInteger(days) ? String(days) : String(days.toFixed(1)); +} diff --git a/src/features/resources/pages/GiftListPage.jsx b/src/features/resources/pages/GiftListPage.jsx new file mode 100644 index 0000000..66f2702 --- /dev/null +++ b/src/features/resources/pages/GiftListPage.jsx @@ -0,0 +1,382 @@ +import Add from "@mui/icons-material/Add"; +import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import MenuItem from "@mui/material/MenuItem"; +import Switch from "@mui/material/Switch"; +import TextField from "@mui/material/TextField"; +import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { RegionSelect } from "@/shared/ui/RegionSelect.jsx"; +import { + AdminActionIconButton, + AdminFilterSelect, + AdminListBody, + AdminListPage, + AdminRowActions, + AdminListToolbar, + AdminSearchBox +} from "@/shared/ui/AdminListLayout.jsx"; +import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; +import { resourceStatusFilters } from "@/features/resources/constants.js"; +import { useGiftListPage } from "@/features/resources/hooks/useResourcePages.js"; +import styles from "@/features/resources/resources.module.css"; + +const baseColumns = [ + { + key: "gift", + label: "礼物", + width: "minmax(260px, 1.4fr)", + render: (gift) => + }, + { + key: "resource", + label: "资源", + width: "minmax(220px, 1.1fr)", + render: (gift) => + }, + { + key: "price", + label: "价格", + width: "minmax(120px, 0.65fr)", + render: (gift) => formatNumber(gift.coinPrice) + }, + { + key: "income", + label: "积分 / 热度", + width: "minmax(140px, 0.8fr)", + render: (gift) => ( +
+ {formatNumber(gift.giftPointAmount)} + {formatNumber(gift.heatValue)} +
+ ) + }, + { + key: "regions", + label: "区域", + width: "minmax(180px, 0.95fr)" + }, + { + key: "status", + label: "状态", + width: "minmax(92px, 0.55fr)" + }, + { + key: "time", + label: "更新时间", + width: "minmax(170px, 0.9fr)", + render: (gift) => formatMillis(gift.updatedAtMs || gift.createdAtMs) + }, + { + key: "actions", + label: "操作", + width: "minmax(76px, 0.4fr)" + } +]; + +export function GiftListPage() { + const page = useGiftListPage(); + const items = page.data.items || []; + const total = page.data.total || 0; + const isEditing = page.activeAction === "edit"; + const giftRegionOptions = [{ label: "全局 · 0", value: "0" }, ...page.regionOptions]; + const regionLabelById = buildRegionLabelMap(giftRegionOptions); + const tableColumns = baseColumns.map((column) => { + if (column.key === "regions") { + return { + ...column, + render: (gift) => + }; + } + if (column.key === "status") { + return { + ...column, + render: (gift) => + }; + } + if (column.key === "actions") { + return { + ...column, + render: (gift) => + }; + } + return column; + }); + + return ( + + + + + + + } + actions={ + page.abilities.canCreateGift ? ( + + + + ) : null + } + /> + + + gift.giftId} /> + {total > 0 ? ( + + ) : null} + + + + + ); +} + +function GiftRowActions({ gift, page }) { + return ( + + page.openEditGift(gift)} + > + + + + ); +} + +function GiftStatusSwitch({ gift, page }) { + const checked = gift.status === "active"; + return ( + page.toggleGift(gift, event.target.checked)} + /> + ); +} + +function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page, regionOptions }) { + const submitDisabled = disabled || loading || page.resourceOptionsLoading || page.loadingRegions; + return ( + + page.setForm({ ...form, name: event.target.value })} + /> + page.setForm({ ...form, giftId: event.target.value })} + /> + page.setForm({ ...form, resourceId: event.target.value })} + > + {page.resourceOptions.map((resource) => ( + + {resource.name || resource.resourceCode || resource.resourceId} + + ))} + + page.setForm({ ...form, coinPrice: event.target.value })} + /> + page.setForm({ ...form, giftPointAmount: event.target.value })} + /> + page.setForm({ ...form, heatValue: event.target.value })} + /> + page.setForm({ ...form, sortOrder: event.target.value })} + /> + page.setForm({ ...form, priceVersion: event.target.value })} + /> + page.setForm({ ...form, regionIds })} + /> + page.setForm({ ...form, presentationJson: event.target.value })} + /> + page.setForm({ ...form, enabled: event.target.checked })} + /> + } + label={form.enabled ? "启用" : "禁用"} + /> + + ); +} + +function GiftRegionSelect({ disabled, labels, onChange, options, value }) { + const selected = value || []; + const mergedOptions = [...new Set([...options.map((option) => option.value), ...selected])].sort( + (left, right) => Number(left) - Number(right) + ); + return ( + + selectedValue.map((regionId) => labels[String(regionId)] || regionId).join("、") + }} + value={selected} + onChange={(event) => { + const nextValue = event.target.value; + onChange(typeof nextValue === "string" ? nextValue.split(",") : nextValue); + }} + > + {mergedOptions.map((regionId) => ( + + {labels[String(regionId)] || regionId} + + ))} + + ); +} + +function RegionTags({ regionIds = [], regionLabelById }) { + const items = [...new Set(regionIds)].sort((left, right) => Number(left) - Number(right)); + if (!items.length) { + return "-"; + } + return ( +
+ {items.map((regionId) => ( + {regionLabelById[String(regionId)] || regionId} + ))} +
+ ); +} + +function GiftIdentity({ gift }) { + const preview = imageURL(gift.resource?.previewUrl || gift.resource?.assetUrl); + return ( +
+ + {preview ? : } + +
+ {gift.name || "-"} + {gift.giftId} +
+
+ ); +} + +function ResourceRef({ gift }) { + const resource = gift.resource || {}; + return ( +
+ {resource.name || "-"} + {resource.resourceCode || gift.resourceId || "-"} +
+ ); +} + +function imageURL(value) { + const url = String(value || "").trim(); + if (!url) { + return ""; + } + return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : ""; +} + +function formatNumber(value) { + return Number(value || 0).toLocaleString("zh-CN"); +} + +function buildRegionLabelMap(options = []) { + return options.reduce((acc, option) => { + acc[option.value] = shortRegionLabel(option.label); + return acc; + }, {}); +} + +function shortRegionLabel(label) { + return String(label || "").split(" · ")[0] || String(label || ""); +} diff --git a/src/features/resources/pages/ResourceGrantListPage.jsx b/src/features/resources/pages/ResourceGrantListPage.jsx new file mode 100644 index 0000000..7718756 --- /dev/null +++ b/src/features/resources/pages/ResourceGrantListPage.jsx @@ -0,0 +1,263 @@ +import Add from "@mui/icons-material/Add"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { + AdminActionIconButton, + AdminFilterSelect, + AdminListBody, + AdminListPage, + AdminListToolbar, + AdminSearchBox, +} from "@/shared/ui/AdminListLayout.jsx"; +import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; +import { + resourceGrantStatusFilters, + resourceGrantStatusLabels, + resourceGrantSubjectLabels, +} from "@/features/resources/constants.js"; +import { useResourceGrantListPage } from "@/features/resources/hooks/useResourcePages.js"; +import styles from "@/features/resources/resources.module.css"; + +const grantColumns = [ + { + key: "grant", + label: "赠送记录", + width: "minmax(240px, 1.2fr)", + render: (grant) => ( +
+ {grant.grantId || "-"} + {grant.commandId || "-"} +
+ ), + }, + { + key: "target", + label: "用户 ID", + width: "minmax(120px, 0.7fr)", + render: (grant) => grant.targetUserId || "-", + }, + { + key: "subject", + label: "对象", + width: "minmax(160px, 0.8fr)", + render: (grant) => ( +
+ {resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "-"} + {grant.grantSubjectId || "-"} +
+ ), + }, + { + key: "items", + label: "发放内容", + width: "minmax(180px, 1fr)", + render: (grant) => , + }, + { + key: "status", + label: "状态", + width: "minmax(100px, 0.55fr)", + render: (grant) => ( + + + {resourceGrantStatusLabels[grant.status] || grant.status || "-"} + + ), + }, + { + key: "reason", + label: "原因", + width: "minmax(160px, 1fr)", + render: (grant) => grant.reason || "-", + }, + { + key: "time", + label: "创建时间", + width: "minmax(170px, 0.9fr)", + render: (grant) => formatMillis(grant.createdAtMs), + }, +]; + +export function ResourceGrantListPage() { + const page = useResourceGrantListPage(); + const items = page.data.items || []; + const total = page.data.total || 0; + + return ( + + + + + + } + actions={ + page.abilities.canCreateGrant ? ( + + + + ) : null + } + /> + + + grant.grantId} /> + {total > 0 ? ( + + ) : null} + + + + + ); +} + +function ResourceGrantDialog({ + disabled, + form, + groupOptions, + loading, + onClose, + onSubmit, + open, + optionsLoading, + resourceOptions, + setForm, +}) { + const submitDisabled = disabled || loading || optionsLoading; + return ( + + setForm({ ...form, targetUserId: event.target.value })} + /> + setForm({ ...form, groupId: "", resourceId: "", subjectType: event.target.value })} + > + 资源 + 资源组 + + {form.subjectType === "resource" ? ( + <> + setForm({ ...form, resourceId: event.target.value })} + > + {resourceOptions.map((resource) => ( + + {resource.name || resource.resourceCode || resource.resourceId} + + ))} + + setForm({ ...form, quantity: event.target.value })} + /> + setForm({ ...form, durationDays: event.target.value })} + /> + + ) : ( + setForm({ ...form, groupId: event.target.value })} + > + {groupOptions.map((group) => ( + + {group.name || group.groupCode || group.groupId} + + ))} + + )} + setForm({ ...form, reason: event.target.value })} + /> + + ); +} + +function GrantItems({ grant }) { + const items = grant.items || []; + if (!items.length) { + return "-"; + } + return ( +
+ {items.slice(0, 2).map((item) => ( + + {item.resourceId || "-"} / {item.quantity || 0} + {item.durationMs ? ` / ${formatDuration(item.durationMs)}` : ""} + + ))} + {items.length > 2 ? +{items.length - 2} : null} +
+ ); +} + +function formatDuration(durationMs) { + const days = Number(durationMs || 0) / (24 * 60 * 60 * 1000); + return Number.isInteger(days) ? `${days}天` : `${days.toFixed(1)}天`; +} diff --git a/src/features/resources/pages/ResourceGroupListPage.jsx b/src/features/resources/pages/ResourceGroupListPage.jsx new file mode 100644 index 0000000..cbc4007 --- /dev/null +++ b/src/features/resources/pages/ResourceGroupListPage.jsx @@ -0,0 +1,380 @@ +import Add from "@mui/icons-material/Add"; +import CategoryOutlined from "@mui/icons-material/CategoryOutlined"; +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; +import DiamondOutlined from "@mui/icons-material/DiamondOutlined"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined"; +import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import MenuItem from "@mui/material/MenuItem"; +import Switch from "@mui/material/Switch"; +import TextField from "@mui/material/TextField"; +import { + AdminFormDialog, + AdminFormFieldGrid, + AdminFormInlineActions, + AdminFormList, + AdminFormListRow, + AdminFormSection, +} from "@/shared/ui/AdminFormDialog.jsx"; +import { Button } from "@/shared/ui/Button.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { + AdminActionIconButton, + AdminFilterSelect, + AdminListBody, + AdminListPage, + AdminListToolbar, + AdminRowActions, + AdminSearchBox, +} from "@/shared/ui/AdminListLayout.jsx"; +import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; +import { + resourceGroupAssetLabels, + resourceStatusFilters, + resourceTypeLabels, +} from "@/features/resources/constants.js"; +import { useResourceGroupListPage } from "@/features/resources/hooks/useResourcePages.js"; +import styles from "@/features/resources/resources.module.css"; + +const dayMillis = 24 * 60 * 60 * 1000; + +const baseColumns = [ + { + key: "group", + label: "资源组", + width: "minmax(260px, 1.5fr)", + render: (group) => , + }, + { + key: "items", + label: "组内资源", + width: "minmax(320px, 1.7fr)", + render: (group) => , + }, + { + key: "sort", + label: "排序", + width: "minmax(80px, 0.45fr)", + render: (group) => group.sortOrder ?? 0, + }, + { + key: "status", + label: "状态", + width: "minmax(92px, 0.55fr)", + }, + { + key: "time", + label: "更新时间", + width: "minmax(170px, 0.9fr)", + render: (group) => formatMillis(group.updatedAtMs || group.createdAtMs), + }, + { + key: "actions", + label: "操作", + width: "minmax(76px, 0.4fr)", + }, +]; + +export function ResourceGroupListPage() { + const page = useResourceGroupListPage(); + const items = page.data.items || []; + const total = page.data.total || 0; + const columns = baseColumns.map((column) => + column.key === "status" + ? { + ...column, + render: (group) => , + } + : column.key === "actions" + ? { + ...column, + render: (group) => , + } + : column, + ); + + return ( + + + + + + } + actions={ + page.abilities.canCreateGroup ? ( + + + + ) : null + } + /> + + + group.groupId} /> + {total > 0 ? ( + + ) : null} + + + + + ); +} + +function ResourceGroupRowActions({ group, page }) { + return ( + + page.openEditGroup(group)} + > + + + + ); +} + +function ResourceGroupStatusSwitch({ group, page }) { + const checked = group.status === "active"; + return ( + page.toggleGroup(group, event.target.checked)} + /> + ); +} + +function ResourceGroupFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page }) { + const submitDisabled = disabled || loading || page.resourceOptionsLoading; + return ( + + + page.setForm({ ...form, name: event.target.value })} + /> + page.setForm({ ...form, groupCode: event.target.value })} + /> + page.setForm({ ...form, sortOrder: event.target.value })} + /> + + page.setForm({ ...form, description: event.target.value })} + /> + page.setForm({ ...form, enabled: event.target.checked })} + /> + } + label={form.enabled ? "启用" : "禁用"} + /> + + + + + + } + > + + {form.items.map((item, index) => ( + + ))} + + + + ); +} + +function ResourceGroupItemEditor({ disabled, index, item, page }) { + if (item.itemType === "wallet_asset") { + const label = resourceGroupAssetLabels[item.walletAssetType] || "钱包资产"; + return ( + + + page.updateGroupItem(index, { walletAssetAmount: event.target.value })} + /> + page.updateGroupItem(index, { sortOrder: event.target.value })} + /> + page.removeGroupItem(index)}> + + + + ); + } + + return ( + + page.updateGroupItem(index, { resourceId: event.target.value })} + > + {page.resourceOptions.map((resource) => ( + + {resource.name || resource.resourceCode || resource.resourceId} + + ))} + + page.updateGroupItem(index, { durationDays: event.target.value })} + /> + page.updateGroupItem(index, { sortOrder: event.target.value })} + /> + page.removeGroupItem(index)}> + + + + ); +} + +function GroupIdentity({ group }) { + return ( +
+ + + +
+ {group.name || "-"} + {group.groupCode || group.groupId} +
+
+ ); +} + +function GroupItems({ group }) { + const items = group.items || []; + if (!items.length) { + return "-"; + } + return ( +
+ {items.slice(0, 4).map((item) => { + if (item.itemType === "wallet_asset") { + const label = resourceGroupAssetLabels[item.walletAssetType] || item.walletAssetType || "钱包资产"; + return ( + + {label} {formatNumber(item.walletAssetAmount)} + + ); + } + const resource = item.resource || {}; + const type = resourceTypeLabels[resource.resourceType] || resource.resourceType || "资源"; + return ( + + {resource.name || item.resourceId} · {formatDurationDays(item.durationMs)}天 · {type} + + ); + })} + {items.length > 4 ? +{items.length - 4} : null} +
+ ); +} + +function formatDurationDays(durationMs) { + const days = Number(durationMs || 0) / dayMillis; + return Number.isInteger(days) ? days : days.toFixed(1); +} + +function formatNumber(value) { + return Number(value || 0).toLocaleString("zh-CN"); +} diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx new file mode 100644 index 0000000..f73fb9a --- /dev/null +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -0,0 +1,316 @@ +import Add from "@mui/icons-material/Add"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import MenuItem from "@mui/material/MenuItem"; +import Switch from "@mui/material/Switch"; +import TextField from "@mui/material/TextField"; +import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { UploadField } from "@/shared/ui/UploadField.jsx"; +import { + AdminActionIconButton, + AdminFilterSelect, + AdminListBody, + AdminListPage, + AdminRowActions, + AdminListToolbar, + AdminSearchBox, +} from "@/shared/ui/AdminListLayout.jsx"; +import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; +import { + grantStrategyLabels, + resourceStatusFilters, + resourceTypeFilters, + resourceTypeLabels, +} from "@/features/resources/constants.js"; +import { useResourceListPage } from "@/features/resources/hooks/useResourcePages.js"; +import styles from "@/features/resources/resources.module.css"; + +const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value); + +const baseColumns = [ + { + key: "resource", + label: "资源", + width: "minmax(260px, 1.5fr)", + render: (resource) => , + }, + { + key: "type", + label: "类型", + width: "minmax(120px, 0.7fr)", + render: (resource) => resourceTypeLabels[resource.resourceType] || resource.resourceType || "-", + }, + { + key: "grant", + label: "发放规则", + width: "minmax(170px, 1fr)", + render: (resource) => , + }, + { + key: "scopes", + label: "使用范围", + width: "minmax(180px, 1fr)", + render: (resource) => , + }, + { + key: "status", + label: "状态", + width: "minmax(92px, 0.55fr)", + }, + { + key: "time", + label: "更新时间", + width: "minmax(170px, 0.9fr)", + render: (resource) => formatMillis(resource.updatedAtMs || resource.createdAtMs), + }, + { + key: "actions", + label: "操作", + width: "minmax(76px, 0.4fr)", + }, +]; + +export function ResourceListPage() { + const page = useResourceListPage(); + const items = page.data.items || []; + const total = page.data.total || 0; + const createDisabled = !page.abilities.canCreate || !page.abilities.canUpload; + const columns = baseColumns.map((column) => + column.key === "status" + ? { + ...column, + render: (resource) => , + } + : column.key === "actions" + ? { + ...column, + render: (resource) => , + } + : column, + ); + + return ( + + + + + + + } + actions={ + page.abilities.canCreate ? ( + + + + ) : null + } + /> + + + resource.resourceId} + /> + {total > 0 ? ( + + ) : null} + + + + + ); +} + +function ResourceRowActions({ page, resource }) { + return ( + + page.openEditResource(resource)} + > + + + + ); +} + +function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, setForm, uploadDisabled }) { + const submitDisabled = disabled || loading; + return ( + + setForm({ ...form, name: event.target.value })} + /> + setForm({ ...form, resourceCode: event.target.value })} + /> + setForm({ ...form, resourceType: event.target.value, walletAssetAmount: "" })} + > + {resourceTypeOptions.map(([value, label]) => ( + + {label} + + ))} + + {form.resourceType === "coin" ? ( + setForm({ ...form, walletAssetAmount: event.target.value })} + /> + ) : null} + setForm({ ...form, previewUrl })} + /> + setForm({ ...form, assetUrl })} + /> + setForm({ ...form, enabled: event.target.checked })} + /> + } + label={form.enabled ? "启用" : "禁用"} + /> + + ); +} + +function ResourceIdentity({ resource }) { + const preview = imageURL(resource.previewUrl || resource.assetUrl); + return ( +
+ + {preview ? : } + +
+ {resource.name || "-"} + {resource.resourceCode || resource.resourceId} +
+
+ ); +} + +function GrantInfo({ resource }) { + const strategy = grantStrategyLabels[resource.grantStrategy] || resource.grantStrategy || "-"; + if (resource.walletAssetType || resource.walletAssetAmount) { + return ( +
+ {strategy} + + {resource.walletAssetType || "-"} {formatNumber(resource.walletAssetAmount)} + +
+ ); + } + return strategy; +} + +function ResourceStatusSwitch({ page, resource }) { + const checked = resource.status === "active"; + return ( + page.toggleResource(resource, event.target.checked)} + /> + ); +} + +function TagList({ values = [] }) { + const items = values.filter(Boolean); + if (!items.length) { + return "-"; + } + return ( +
+ {items.map((value) => ( + + {value} + + ))} +
+ ); +} + +function imageURL(value) { + const url = String(value || "").trim(); + if (!url) { + return ""; + } + return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : ""; +} + +function formatNumber(value) { + return Number(value || 0).toLocaleString("zh-CN"); +} diff --git a/src/features/resources/permissions.js b/src/features/resources/permissions.js new file mode 100644 index 0000000..8c925da --- /dev/null +++ b/src/features/resources/permissions.js @@ -0,0 +1,20 @@ +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { PERMISSIONS } from "@/app/permissions"; + +export function useResourceAbilities() { + const { can } = useAuth(); + + return { + canCreate: can(PERMISSIONS.resourceCreate), + canCreateGift: can(PERMISSIONS.giftCreate), + canCreateGrant: can(PERMISSIONS.resourceGrantCreate), + canCreateGroup: can(PERMISSIONS.resourceGroupCreate), + canStatusGift: can(PERMISSIONS.giftStatus), + canUpdateGroup: can(PERMISSIONS.resourceGroupUpdate), + canUpdateGift: can(PERMISSIONS.giftUpdate), + canUpdate: can(PERMISSIONS.resourceUpdate), + canUpload: can(PERMISSIONS.uploadCreate), + canView: can(PERMISSIONS.resourceView), + canViewGrant: can(PERMISSIONS.resourceGrantView) + }; +} diff --git a/src/features/resources/resources.module.css b/src/features/resources/resources.module.css new file mode 100644 index 0000000..9e549f3 --- /dev/null +++ b/src/features/resources/resources.module.css @@ -0,0 +1,72 @@ +.identity { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-3); +} + +.thumb { + 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: var(--radius-sm); + background: var(--bg-card-strong); + color: var(--text-secondary); +} + +.thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.identityText, +.stack { + display: grid; + min-width: 0; + gap: 2px; +} + +.name { + overflow: hidden; + color: var(--text-primary); + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.meta { + overflow: hidden; + color: var(--text-tertiary); + font-size: var(--admin-font-size); + text-overflow: ellipsis; + white-space: nowrap; +} + +.tagList { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: var(--space-2); +} + +.tag { + display: inline-flex; + max-width: 100%; + align-items: center; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--neutral-surface); + color: var(--text-secondary); + font-size: var(--admin-font-size); + line-height: 1; + padding: 5px 8px; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/features/resources/routes.js b/src/features/resources/routes.js new file mode 100644 index 0000000..eb6f077 --- /dev/null +++ b/src/features/resources/routes.js @@ -0,0 +1,36 @@ +import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; + +export const resourceRoutes = [ + { + label: "资源列表", + loader: () => import("./pages/ResourceListPage.jsx").then((module) => module.ResourceListPage), + menuCode: MENU_CODES.resourceList, + pageKey: "resource-list", + path: "/resources", + permission: PERMISSIONS.resourceView + }, + { + label: "资源组列表", + loader: () => import("./pages/ResourceGroupListPage.jsx").then((module) => module.ResourceGroupListPage), + menuCode: MENU_CODES.resourceGroupList, + pageKey: "resource-group-list", + path: "/resource-groups", + permission: PERMISSIONS.resourceGroupView + }, + { + label: "礼物列表", + loader: () => import("./pages/GiftListPage.jsx").then((module) => module.GiftListPage), + menuCode: MENU_CODES.giftList, + pageKey: "gift-list", + path: "/gifts", + permission: PERMISSIONS.giftView + }, + { + label: "资源赠送", + loader: () => import("./pages/ResourceGrantListPage.jsx").then((module) => module.ResourceGrantListPage), + menuCode: MENU_CODES.resourceGrantList, + pageKey: "resource-grant-list", + path: "/resource-grants", + permission: PERMISSIONS.resourceGrantView + } +]; diff --git a/src/features/resources/schema.js b/src/features/resources/schema.js new file mode 100644 index 0000000..9041298 --- /dev/null +++ b/src/features/resources/schema.js @@ -0,0 +1,235 @@ +import { z } from "zod"; + +const resourceTypes = ["avatar_frame", "coin", "vehicle", "chat_bubble", "badge", "floating_screen", "gift"]; +const walletAssetTypes = ["COIN", "DIAMOND"]; +const optionalWalletAssetTypeSchema = z + .preprocess((value) => { + if (typeof value !== "string") { + return value; + } + const normalized = value.trim().toUpperCase(); + return normalized || undefined; + }, z.string().optional()) + .refine((value) => !value || walletAssetTypes.includes(value), "请选择金币或钻石"); + +export const resourceCreateFormSchema = z + .object({ + assetUrl: z.string().trim().min(1, "请上传资源素材").max(512, "资源素材不能超过 512 个字符"), + enabled: z.boolean(), + name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"), + previewUrl: z.string().trim().min(1, "请上传资源封面").max(512, "资源封面不能超过 512 个字符"), + resourceCode: z.string().trim().min(1, "请输入资源编码").max(96, "资源编码不能超过 96 个字符"), + resourceType: z.enum(resourceTypes, "请选择资源类型"), + walletAssetAmount: z.union([z.string(), z.number()]).optional(), + }) + .superRefine((value, context) => { + if (value.resourceType !== "coin") { + return; + } + const amount = Number(value.walletAssetAmount); + if (!Number.isInteger(amount) || amount <= 0) { + context.addIssue({ + code: "custom", + message: "请输入金币数量", + path: ["walletAssetAmount"], + }); + } + }); + +export const resourceFormSchema = resourceCreateFormSchema; + +const groupItemSchema = z.object({ + durationDays: z.union([z.string(), z.number()]).optional(), + itemType: z.enum(["resource", "wallet_asset"]), + resourceId: z.union([z.string(), z.number()]).optional(), + sortOrder: z.union([z.string(), z.number()]).optional(), + walletAssetAmount: z.union([z.string(), z.number()]).optional(), + walletAssetType: optionalWalletAssetTypeSchema, +}); + +export const resourceGroupCreateFormSchema = z + .object({ + description: z.string().trim().max(512, "资源组说明不能超过 512 个字符").optional(), + enabled: z.boolean(), + groupCode: z.string().trim().min(1, "请输入资源组编码").max(96, "资源组编码不能超过 96 个字符"), + items: z.array(groupItemSchema).min(1, "请至少配置一个资源组条目"), + name: z.string().trim().min(1, "请输入资源组名称").max(128, "资源组名称不能超过 128 个字符"), + sortOrder: z.union([z.string(), z.number()]).optional(), + }) + .superRefine((value, context) => { + const resourceIds = new Set(); + const walletAssets = new Set(); + value.items.forEach((item, index) => { + if (item.itemType === "wallet_asset") { + const amount = Number(item.walletAssetAmount); + if (!item.walletAssetType || !Number.isInteger(amount) || amount <= 0) { + context.addIssue({ + code: "custom", + message: "请输入金币或钻石数量", + path: ["items", index, "walletAssetAmount"], + }); + } + if (item.walletAssetType) { + if (walletAssets.has(item.walletAssetType)) { + context.addIssue({ + code: "custom", + message: "同一种钱包资产不能重复配置", + path: ["items", index, "walletAssetType"], + }); + } + walletAssets.add(item.walletAssetType); + } + return; + } + + const resourceId = Number(item.resourceId); + const durationDays = Number(item.durationDays); + if (!Number.isInteger(resourceId) || resourceId <= 0) { + context.addIssue({ + code: "custom", + message: "请选择资源", + path: ["items", index, "resourceId"], + }); + } + if (!Number.isInteger(durationDays) || durationDays <= 0) { + context.addIssue({ + code: "custom", + message: "请输入有效天数", + path: ["items", index, "durationDays"], + }); + } + if (resourceIds.has(resourceId)) { + context.addIssue({ + code: "custom", + message: "同一个资源不能重复配置", + path: ["items", index, "resourceId"], + }); + } + resourceIds.add(resourceId); + }); + }); + +export const giftFormSchema = z + .object({ + coinPrice: z.union([z.string(), z.number()]), + enabled: z.boolean(), + giftId: z.string().trim().min(1, "请输入礼物 ID").max(96, "礼物 ID 不能超过 96 个字符"), + giftPointAmount: z.union([z.string(), z.number()]).optional(), + heatValue: z.union([z.string(), z.number()]).optional(), + name: z.string().trim().min(1, "请输入礼物名称").max(128, "礼物名称不能超过 128 个字符"), + presentationJson: z.string().trim().optional(), + priceVersion: z.string().trim().min(1, "请输入价格版本").max(64, "价格版本不能超过 64 个字符"), + regionIds: z.array(z.union([z.string(), z.number()])).min(1, "请选择区域"), + resourceId: z.union([z.string(), z.number()]), + sortOrder: z.union([z.string(), z.number()]).optional(), + }) + .superRefine((value, context) => { + const resourceId = Number(value.resourceId); + const coinPrice = Number(value.coinPrice); + const giftPointAmount = Number(value.giftPointAmount || 0); + const heatValue = Number(value.heatValue || 0); + + if (!Number.isInteger(resourceId) || resourceId <= 0) { + context.addIssue({ + code: "custom", + message: "请选择礼物资源", + path: ["resourceId"], + }); + } + if (!Number.isInteger(coinPrice) || coinPrice <= 0) { + context.addIssue({ + code: "custom", + message: "请输入价格", + path: ["coinPrice"], + }); + } + if (!Number.isInteger(giftPointAmount) || giftPointAmount < 0) { + context.addIssue({ + code: "custom", + message: "请输入有效积分", + path: ["giftPointAmount"], + }); + } + if (!Number.isInteger(heatValue) || heatValue < 0) { + context.addIssue({ + code: "custom", + message: "请输入有效热度", + path: ["heatValue"], + }); + } + value.regionIds.forEach((regionId, index) => { + const normalizedRegionId = Number(regionId); + if (!Number.isInteger(normalizedRegionId) || normalizedRegionId < 0) { + context.addIssue({ + code: "custom", + message: "请选择区域", + path: ["regionIds", index], + }); + } + }); + if (value.presentationJson) { + try { + JSON.parse(value.presentationJson); + } catch { + context.addIssue({ + code: "custom", + message: "展示配置必须是合法 JSON", + path: ["presentationJson"], + }); + } + } + }); + +export const resourceGrantFormSchema = z + .object({ + durationDays: z.union([z.string(), z.number()]).optional(), + groupId: z.union([z.string(), z.number()]).optional(), + quantity: z.union([z.string(), z.number()]).optional(), + reason: z.string().trim().min(1, "请输入原因").max(240, "原因不能超过 240 个字符"), + resourceId: z.union([z.string(), z.number()]).optional(), + subjectType: z.enum(["resource", "resource_group"]), + targetUserId: z.union([z.string(), z.number()]), + }) + .superRefine((value, context) => { + const targetUserId = Number(value.targetUserId); + const resourceId = Number(value.resourceId || 0); + const groupId = Number(value.groupId || 0); + const quantity = Number(value.quantity || 1); + const durationDays = Number(value.durationDays || 0); + + if (!Number.isInteger(targetUserId) || targetUserId <= 0) { + context.addIssue({ + code: "custom", + message: "请输入用户 ID", + path: ["targetUserId"], + }); + } + if (value.subjectType === "resource" && (!Number.isInteger(resourceId) || resourceId <= 0)) { + context.addIssue({ + code: "custom", + message: "请选择资源", + path: ["resourceId"], + }); + } + if (value.subjectType === "resource_group" && (!Number.isInteger(groupId) || groupId <= 0)) { + context.addIssue({ + code: "custom", + message: "请选择资源组", + path: ["groupId"], + }); + } + if (!Number.isInteger(quantity) || quantity <= 0) { + context.addIssue({ + code: "custom", + message: "请输入数量", + path: ["quantity"], + }); + } + if (!Number.isFinite(durationDays) || durationDays < 0) { + context.addIssue({ + code: "custom", + message: "请输入有效天数", + path: ["durationDays"], + }); + } + }); diff --git a/src/features/resources/schema.test.ts b/src/features/resources/schema.test.ts new file mode 100644 index 0000000..97cfc80 --- /dev/null +++ b/src/features/resources/schema.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "vitest"; +import { FormValidationError, parseForm } from "@/shared/forms/validation"; +import { giftFormSchema, resourceGrantFormSchema, resourceGroupCreateFormSchema } from "./schema.js"; + +describe("resource form schema", () => { + test("allows resource group resource items without wallet asset type", () => { + const payload = parseForm(resourceGroupCreateFormSchema, { + description: "", + enabled: true, + groupCode: "starter_pack", + items: [ + { + durationDays: "7", + itemType: "resource", + resourceId: "11", + sortOrder: "0", + walletAssetAmount: "", + walletAssetType: "" + } + ], + name: "Starter Pack", + sortOrder: "0" + }); + + expect(payload.items[0].walletAssetType).toBeUndefined(); + }); + + test("rejects invalid wallet asset type with Chinese message", () => { + expect(() => + parseForm(resourceGroupCreateFormSchema, { + description: "", + enabled: true, + groupCode: "starter_pack", + items: [ + { + durationDays: "", + itemType: "wallet_asset", + resourceId: "", + sortOrder: "0", + walletAssetAmount: "100", + walletAssetType: "gold" + } + ], + name: "Starter Pack", + sortOrder: "0" + }) + ).toThrow(FormValidationError); + }); + + test("validates editable gift form fields", () => { + const payload = parseForm(giftFormSchema, { + coinPrice: "10", + enabled: true, + giftId: "rose", + giftPointAmount: "1", + heatValue: "2", + name: "Rose", + presentationJson: "{}", + priceVersion: "default", + regionIds: ["0", "1001"], + resourceId: "11", + sortOrder: "0" + }); + + expect(payload.regionIds).toEqual(["0", "1001"]); + }); + + test("validates resource grant form fields", () => { + const payload = parseForm(resourceGrantFormSchema, { + durationDays: "7", + quantity: "1", + reason: "manual", + resourceId: "11", + subjectType: "resource", + targetUserId: "1001" + }); + + expect(payload.subjectType).toBe("resource"); + }); +}); diff --git a/src/features/roles/api.ts b/src/features/roles/api.ts new file mode 100644 index 0000000..0d991f3 --- /dev/null +++ b/src/features/roles/api.ts @@ -0,0 +1,69 @@ +import { apiRequest } from "@/shared/api/request"; +import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import type { EntityId, PermissionDto, PermissionPayload, RoleDto, RolePayload } from "@/shared/api/types"; + +export function listRoles(): Promise { + return apiRequest(apiEndpointPath(API_OPERATIONS.listRoles)); +} + +export function createRole(payload: RolePayload): Promise { + const endpoint = API_ENDPOINTS.createRole; + return apiRequest(apiEndpointPath(API_OPERATIONS.createRole), { + body: payload, + method: endpoint.method + }); +} + +export function updateRole(id: EntityId, payload: RolePayload): Promise { + const endpoint = API_ENDPOINTS.updateRole; + return apiRequest(apiEndpointPath(API_OPERATIONS.updateRole, { id }), { + body: payload, + method: endpoint.method + }); +} + +export function deleteRole(id: EntityId): Promise { + const endpoint = API_ENDPOINTS.deleteRole; + return apiRequest(apiEndpointPath(API_OPERATIONS.deleteRole, { id }), { method: endpoint.method }); +} + +export function replaceRolePermissions(id: EntityId, permissionIds: number[]): Promise { + const endpoint = API_ENDPOINTS.replaceRolePermissions; + return apiRequest(apiEndpointPath(API_OPERATIONS.replaceRolePermissions, { id }), { + body: { permissionIds }, + method: endpoint.method + }); +} + +export function listPermissions(): Promise { + return apiRequest(apiEndpointPath(API_OPERATIONS.listPermissions)); +} + +export function createPermission(payload: PermissionPayload): Promise { + const endpoint = API_ENDPOINTS.createPermission; + return apiRequest(apiEndpointPath(API_OPERATIONS.createPermission), { + body: payload, + method: endpoint.method + }); +} + +export function updatePermission(id: EntityId, payload: PermissionPayload): Promise { + const endpoint = API_ENDPOINTS.updatePermission; + return apiRequest(apiEndpointPath(API_OPERATIONS.updatePermission, { id }), { + body: payload, + method: endpoint.method + }); +} + +export function deletePermission(id: EntityId): Promise { + const endpoint = API_ENDPOINTS.deletePermission; + return apiRequest(apiEndpointPath(API_OPERATIONS.deletePermission, { id }), { method: endpoint.method }); +} + +export function syncPermissions(): Promise { + const endpoint = API_ENDPOINTS.syncPermissions; + return apiRequest>(apiEndpointPath(API_OPERATIONS.syncPermissions), { + body: {}, + method: endpoint.method + }); +} diff --git a/src/features/roles/components/RoleFormDrawer.jsx b/src/features/roles/components/RoleFormDrawer.jsx new file mode 100644 index 0000000..13e933e --- /dev/null +++ b/src/features/roles/components/RoleFormDrawer.jsx @@ -0,0 +1,87 @@ +import Checkbox from "@mui/material/Checkbox"; +import Drawer from "@mui/material/Drawer"; +import TextField from "@mui/material/TextField"; +import { Button } from "@/shared/ui/Button.jsx"; + +export function RoleFormDrawer({ + abilities, + editingRole, + form, + onClose, + onSubmit, + onTogglePermissionGroup, + onTogglePermission, + open, + permissionGroups, + setForm +}) { + return ( + +
+

{editingRole ? "编辑角色" : "新增角色"}

+ setForm({ ...form, name: event.target.value })} /> + setForm({ ...form, code: event.target.value })} /> + setForm({ ...form, description: event.target.value })} /> + {abilities.canLoadPermissions ? ( +
+ {permissionGroups.map((group) => ( + + ))} +
+ ) : null} +
+ + +
+ +
+ ); +} + +function PermissionMenuGroup({ abilities, group, onTogglePermission, onTogglePermissionGroup, permissionIds }) { + const groupPermissionIds = group.items.map((permission) => permission.id); + const selectedCount = groupPermissionIds.filter((permissionId) => permissionIds.includes(permissionId)).length; + const allSelected = selectedCount === groupPermissionIds.length; + const partiallySelected = selectedCount > 0 && !allSelected; + + return ( +
+
+ {group.title} +
+ {selectedCount}/{groupPermissionIds.length} + +
+
+
+ {group.items.map((permission) => ( + + ))} +
+
+ ); +} diff --git a/src/features/roles/components/RoleTable.jsx b/src/features/roles/components/RoleTable.jsx new file mode 100644 index 0000000..41a127c --- /dev/null +++ b/src/features/roles/components/RoleTable.jsx @@ -0,0 +1,41 @@ +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; + +export function RoleTable({ abilities, onEdit, onRemove, roles }) { + const columns = [ + { key: "name", label: "角色", width: "minmax(160px, 1fr)" }, + { key: "code", label: "编码", width: "minmax(180px, 1.2fr)", render: (role) => {role.code} }, + { key: "permissionCount", label: "权限数量", width: "110px", render: (role) => role.permissions?.length || 0 }, + { + key: "description", + label: "说明", + width: "minmax(240px, 1.6fr)", + render: (role) => {role.description || "-"} + }, + { + key: "actions", + label: "操作", + width: "120px", + render: (role) => ( + + {abilities.canUpdate || abilities.canGrant ? ( + onEdit(role)}> + + + ) : null} + {abilities.canDelete ? ( + onRemove(role)}> + + + ) : null} + + ) + } + ]; + + return ( + role.id} /> + ); +} diff --git a/src/features/roles/components/RoleToolbar.jsx b/src/features/roles/components/RoleToolbar.jsx new file mode 100644 index 0000000..99a0fce --- /dev/null +++ b/src/features/roles/components/RoleToolbar.jsx @@ -0,0 +1,16 @@ +import Add from "@mui/icons-material/Add"; +import { Button } from "@/shared/ui/Button.jsx"; +import { PageHead } from "@/shared/ui/PageHead.jsx"; + +export function RoleToolbar({ canCreate, onCreate }) { + return ( + + {canCreate ? ( + + ) : null} + + ); +} diff --git a/src/features/roles/hooks/useRolesPage.js b/src/features/roles/hooks/useRolesPage.js new file mode 100644 index 0000000..8b09c2e --- /dev/null +++ b/src/features/roles/hooks/useRolesPage.js @@ -0,0 +1,236 @@ +import { useCallback, useMemo, useState } from "react"; +import { parseForm } from "@/shared/forms/validation"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { + createRole, + deleteRole, + listPermissions, + listRoles, + replaceRolePermissions, + updateRole +} from "@/features/roles/api"; +import { useRoleAbilities } from "@/features/roles/permissions.js"; +import { roleFormSchema } from "@/features/roles/schema"; + +const emptyRole = { code: "", description: "", name: "", permissionIds: [] }; +const emptyData = { permissions: [], roles: [] }; +const permissionGroupDefinitions = [ + { key: "overview", prefixes: ["overview"], title: "总览" }, + { key: "users", prefixes: ["user"], title: "用户管理" }, + { key: "roles", prefixes: ["role"], title: "角色配置" }, + { key: "menus", prefixes: ["menu", "permission"], title: "菜单权限" }, + { key: "app-users", prefixes: ["app-user"], title: "App 用户" }, + { key: "rooms", prefixes: ["room"], title: "房间管理" }, + { key: "countries", prefixes: ["country"], title: "国家管理" }, + { key: "regions", prefixes: ["region"], title: "区域管理" }, + { key: "agencies", prefixes: ["agency"], title: "Agency 管理" }, + { key: "bds", prefixes: ["bd"], title: "BD 管理" }, + { key: "hosts", prefixes: ["host"], title: "主播管理" }, + { key: "logs", prefixes: ["log"], title: "日志审计" }, + { key: "notifications", prefixes: ["notification"], title: "通知中心" }, + { key: "jobs", prefixes: ["job", "export"], title: "任务中心" }, + { key: "common", prefixes: ["upload"], title: "通用能力" } +]; +const fallbackPermissionGroup = { key: "other", title: "其他权限" }; +const permissionKindOrder = { menu: 1, button: 2, api: 3 }; + +export function useRolesPage() { + const [query, setQuery] = useState(""); + const [drawerOpen, setDrawerOpen] = useState(false); + const [editingRole, setEditingRole] = useState(null); + const [form, setForm] = useState(emptyRole); + const abilities = useRoleAbilities(); + const confirm = useConfirm(); + const { showToast } = useToast(); + + const queryFn = useCallback(async () => { + const roleData = await listRoles(); + const permissionData = abilities.canLoadPermissions ? await listPermissions() : []; + return { permissions: permissionData || [], roles: roleData || [] }; + }, [abilities.canLoadPermissions]); + + const { data, error, loading, reload } = useAdminQuery(queryFn, { + errorMessage: "加载角色失败", + initialData: emptyData, + keepPreviousData: true, + queryKey: ["roles", abilities.canLoadPermissions] + }); + + const permissionGroups = useMemo(() => { + return groupPermissionsByMenu(data.permissions || []); + }, [data.permissions]); + + const roles = useMemo(() => { + const keyword = query.trim().toLowerCase(); + const items = data.roles || []; + if (!keyword) { + return items; + } + return items.filter((role) => { + return [role.name, role.code, role.description].some((value) => String(value || "").toLowerCase().includes(keyword)); + }); + }, [data.roles, query]); + + const changeQuery = (value) => { + setQuery(value); + }; + + const openCreate = () => { + setEditingRole(null); + setForm(emptyRole); + setDrawerOpen(true); + }; + + const openEdit = (role) => { + setEditingRole(role); + setForm({ + code: role.code, + description: role.description || "", + name: role.name, + permissionIds: role.permissions?.map((permission) => permission.id) || [] + }); + setDrawerOpen(true); + }; + + const closeDrawer = () => { + setDrawerOpen(false); + }; + + const submit = async (event) => { + event.preventDefault(); + try { + const payload = parseForm(roleFormSchema, form); + if (editingRole) { + if (abilities.canUpdate) { + await updateRole(editingRole.id, { + code: payload.code, + description: payload.description, + name: payload.name + }); + } + if (abilities.canGrant) { + await replaceRolePermissions(editingRole.id, payload.permissionIds); + } + showToast("角色已更新", "success"); + } else { + await createRole({ + code: payload.code, + description: payload.description, + name: payload.name, + permissionIds: abilities.canGrant ? payload.permissionIds : [] + }); + showToast("角色已创建", "success"); + } + setDrawerOpen(false); + reload(); + } catch (err) { + showToast(err.message || "保存角色失败", "error"); + } + }; + + const remove = async (role) => { + const ok = await confirm({ + confirmText: "删除", + message: `${role.name} 删除前需要先解绑用户。`, + title: "删除角色", + tone: "danger" + }); + if (!ok) { + return; + } + try { + await deleteRole(role.id); + showToast("角色已删除", "success"); + reload(); + } catch (err) { + showToast(err.message || "删除角色失败", "error"); + } + }; + + const togglePermission = (permissionId, checked) => { + if (!abilities.canGrant) { + return; + } + setForm((current) => ({ + ...current, + permissionIds: checked + ? Array.from(new Set([...current.permissionIds, permissionId])) + : current.permissionIds.filter((id) => id !== permissionId) + })); + }; + + const togglePermissionGroup = (permissionIds, checked) => { + if (!abilities.canGrant) { + return; + } + setForm((current) => { + const targetIds = new Set(permissionIds); + + return { + ...current, + permissionIds: checked + ? Array.from(new Set([...current.permissionIds, ...permissionIds])) + : current.permissionIds.filter((id) => !targetIds.has(id)) + }; + }); + }; + + return { + abilities, + changeQuery, + closeDrawer, + drawerOpen, + editingRole, + error, + form, + loading, + openCreate, + openEdit, + permissionGroups, + permissions: data.permissions || [], + query, + reload, + remove, + roles, + setForm, + submit, + togglePermission, + togglePermissionGroup + }; +} + +function groupPermissionsByMenu(permissions) { + const groups = new Map(permissionGroupDefinitions.map((definition) => [definition.key, { ...definition, items: [] }])); + const prefixToGroup = new Map( + permissionGroupDefinitions.flatMap((definition) => definition.prefixes.map((prefix) => [prefix, definition.key])) + ); + const fallbackGroup = { ...fallbackPermissionGroup, items: [] }; + + permissions.forEach((permission) => { + const prefix = getPermissionPrefix(permission.code); + const groupKey = prefixToGroup.get(prefix); + const group = groupKey ? groups.get(groupKey) : fallbackGroup; + group.items.push(permission); + }); + + return [...groups.values(), fallbackGroup] + .filter((group) => group.items.length > 0) + .map((group) => ({ + ...group, + items: [...group.items].sort(comparePermissions) + })); +} + +function getPermissionPrefix(code = "") { + return code.split(":")[0] || "other"; +} + +function comparePermissions(left, right) { + const kindDiff = (permissionKindOrder[left.kind] || 9) - (permissionKindOrder[right.kind] || 9); + if (kindDiff !== 0) { + return kindDiff; + } + return left.code.localeCompare(right.code); +} diff --git a/src/features/roles/pages/RoleManagementPage.jsx b/src/features/roles/pages/RoleManagementPage.jsx new file mode 100644 index 0000000..eeca37e --- /dev/null +++ b/src/features/roles/pages/RoleManagementPage.jsx @@ -0,0 +1,53 @@ +import Add from "@mui/icons-material/Add"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { + AdminActionIconButton, + AdminListBody, + AdminListPage, + AdminListToolbar, + AdminSearchBox +} from "@/shared/ui/AdminListLayout.jsx"; +import { RoleFormDrawer } from "@/features/roles/components/RoleFormDrawer.jsx"; +import { RoleTable } from "@/features/roles/components/RoleTable.jsx"; +import { useRolesPage } from "@/features/roles/hooks/useRolesPage.js"; + +export function RoleManagementPage() { + const page = useRolesPage(); + + return ( + <> + + } + actions={page.abilities.canCreate ? ( + + + + ) : null} + /> + + + + +
+ {page.roles.length} 条 +
+
+
+
+ + + + ); +} diff --git a/src/features/roles/permissions.js b/src/features/roles/permissions.js new file mode 100644 index 0000000..b215ef6 --- /dev/null +++ b/src/features/roles/permissions.js @@ -0,0 +1,15 @@ +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { PERMISSIONS } from "@/app/permissions"; + +export function useRoleAbilities() { + const { can } = useAuth(); + const canGrant = can(PERMISSIONS.rolePermission) || can(PERMISSIONS.roleManage); + + return { + canCreate: can(PERMISSIONS.roleCreate) || can(PERMISSIONS.roleManage), + canDelete: can(PERMISSIONS.roleDelete) || can(PERMISSIONS.roleManage), + canGrant, + canLoadPermissions: can(PERMISSIONS.permissionView) || canGrant, + canUpdate: can(PERMISSIONS.roleUpdate) || can(PERMISSIONS.roleManage) + }; +} diff --git a/src/features/roles/routes.js b/src/features/roles/routes.js new file mode 100644 index 0000000..7ec6f3f --- /dev/null +++ b/src/features/roles/routes.js @@ -0,0 +1,12 @@ +import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; + +export const rolesRoutes = [ + { + label: "角色配置", + loader: () => import("./pages/RoleManagementPage.jsx").then((module) => module.RoleManagementPage), + menuCode: MENU_CODES.systemRoles, + pageKey: "roles", + path: "/system/roles", + permission: PERMISSIONS.roleView + } +]; diff --git a/src/features/roles/schema.ts b/src/features/roles/schema.ts new file mode 100644 index 0000000..ca671fc --- /dev/null +++ b/src/features/roles/schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const roleFormSchema = z.object({ + code: z.string().trim().min(1, "请输入角色编码").max(60, "角色编码不能超过 60 个字符"), + description: z.string().trim().max(200, "描述不能超过 200 个字符").optional().default(""), + name: z.string().trim().min(1, "请输入角色名称").max(60, "角色名称不能超过 60 个字符"), + permissionIds: z.array(z.coerce.number().int().positive()).default([]) +}); + +export type RoleForm = z.infer; diff --git a/src/features/rooms/api.ts b/src/features/rooms/api.ts new file mode 100644 index 0000000..0e55875 --- /dev/null +++ b/src/features/rooms/api.ts @@ -0,0 +1,32 @@ +import { apiRequest } from "@/shared/api/request"; +import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import type { + ApiPage, + EntityId, + PageQuery, + RoomDto, + RoomUpdatePayload +} from "@/shared/api/types"; + +export function listRooms(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listRooms; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listRooms), { + method: endpoint.method, + query + }); +} + +export function updateRoom(roomId: EntityId, payload: RoomUpdatePayload): Promise { + const endpoint = API_ENDPOINTS.updateRoom; + return apiRequest(apiEndpointPath(API_OPERATIONS.updateRoom, { room_id: roomId }), { + body: payload, + method: endpoint.method + }); +} + +export function deleteRoom(roomId: EntityId): Promise<{ deleted: boolean }> { + const endpoint = API_ENDPOINTS.deleteRoom; + return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteRoom, { room_id: roomId }), { + method: endpoint.method + }); +} diff --git a/src/features/rooms/constants.js b/src/features/rooms/constants.js new file mode 100644 index 0000000..c91de70 --- /dev/null +++ b/src/features/rooms/constants.js @@ -0,0 +1,10 @@ +export const roomStatusFilters = [ + ["", "全部"], + ["active", "正常"], + ["closed", "关闭"] +]; + +export const roomStatusLabels = { + active: "正常", + closed: "关闭" +}; diff --git a/src/features/rooms/hooks/useRoomsPage.js b/src/features/rooms/hooks/useRoomsPage.js new file mode 100644 index 0000000..72abd08 --- /dev/null +++ b/src/features/rooms/hooks/useRoomsPage.js @@ -0,0 +1,176 @@ +import { useMemo, useState } from "react"; +import { parseForm } from "@/shared/forms/validation"; +import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; +import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { deleteRoom, listRooms, updateRoom } from "@/features/rooms/api"; +import { useRoomAbilities } from "@/features/rooms/permissions.js"; +import { roomStatusSchema, roomUpdateSchema } from "@/features/rooms/schema"; + +const pageSize = 10; +const emptyData = { items: [], page: 1, pageSize, total: 0 }; +const emptyForm = () => ({ + coverUrl: "", + description: "", + title: "", + visibleRegionId: 0 +}); +const emptyStatusForm = () => ({ status: "active" }); + +export function useRoomsPage() { + const abilities = useRoomAbilities(); + 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 [activeRoom, setActiveRoom] = useState(null); + const [form, setForm] = useState(emptyForm); + const [statusForm, setStatusForm] = useState(emptyStatusForm); + 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: listRooms, + filters, + page, + pageSize, + queryKey: ["rooms", filters, page] + }); + + const changeQuery = (value) => { + setQuery(value); + setPage(1); + }; + + const changeStatus = (value) => { + setStatus(value); + setPage(1); + }; + + const changeRegionId = (value) => { + setRegionId(value); + setPage(1); + }; + + const openEdit = (room) => { + setActiveRoom(room); + setForm({ + coverUrl: room.coverUrl || "", + description: "", + title: room.title || "", + visibleRegionId: room.visibleRegionId || 0 + }); + setActiveAction("edit"); + }; + + const openStatus = (room) => { + setActiveRoom(room); + setStatusForm({ status: room.status === "closed" ? "closed" : "active" }); + setActiveAction("status"); + }; + + const closeAction = () => { + setActiveAction(""); + setActiveRoom(null); + }; + + const submitEdit = async (event) => { + event.preventDefault(); + if (!activeRoom) { + return; + } + await runAction("edit", "房间已更新", async () => { + const payload = parseForm(roomUpdateSchema, form); + if (!String(form.description || "").trim()) { + delete payload.description; + } + await updateRoom(activeRoom.roomId, payload); + closeAction(); + await reload(); + }); + }; + + const submitStatus = async (event) => { + event.preventDefault(); + if (!activeRoom) { + return; + } + await runAction("status", "房间状态已更新", async () => { + const payload = parseForm(roomStatusSchema, statusForm); + await updateRoom(activeRoom.roomId, payload); + closeAction(); + await reload(); + }); + }; + + const removeRoom = async (room) => { + const ok = await confirm({ + confirmText: "删除", + message: `${room.title || room.roomId} 会从房间列表和房间状态中删除。`, + title: "删除房间", + tone: "danger" + }); + if (!ok) { + return; + } + await runAction(`delete-${room.roomId}`, "房间已删除", async () => { + await deleteRoom(room.roomId); + 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, + activeRoom, + changeQuery, + changeRegionId, + changeStatus, + closeAction, + data, + error, + form, + loading, + loadingAction, + loadingRegions, + openEdit, + openStatus, + page, + query, + regionId, + regionOptions, + reload, + removeRoom, + setForm, + setPage, + setStatusForm, + status, + statusForm, + submitStatus, + submitEdit + }; +} diff --git a/src/features/rooms/pages/RoomListPage.jsx b/src/features/rooms/pages/RoomListPage.jsx new file mode 100644 index 0000000..25c8c0a --- /dev/null +++ b/src/features/rooms/pages/RoomListPage.jsx @@ -0,0 +1,240 @@ +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined"; +import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined"; +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 { RegionSelect } from "@/shared/ui/RegionSelect.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 { roomStatusFilters, roomStatusLabels } from "@/features/rooms/constants.js"; +import { useRoomsPage } from "@/features/rooms/hooks/useRoomsPage.js"; +import styles from "@/features/rooms/rooms.module.css"; + +const columns = [ + { + key: "identity", + label: "房间", + width: "minmax(240px, 1.5fr)", + render: (room) => + }, + { + key: "owner", + label: "房主", + width: "minmax(210px, 1.2fr)", + render: (room) => + }, + { + key: "region", + label: "区域", + width: "minmax(140px, 0.8fr)", + render: (room) => room.regionName || (room.visibleRegionId ? `区域 ${room.visibleRegionId}` : "-") + }, + { + key: "onlineCount", + label: "房间人数", + width: "minmax(110px, 0.7fr)", + render: (room) => `${formatNumber(room.onlineCount)} 人` + }, + { + key: "status", + label: "状态", + width: "minmax(100px, 0.7fr)", + render: (room) => + }, + { + key: "time", + label: "创建时间", + width: "minmax(170px, 1fr)", + render: (room) => formatMillis(room.createdAtMs) + } +]; + +export function RoomListPage() { + const page = useRoomsPage(); + const items = page.data.items || []; + const total = page.data.total || 0; + const tableColumns = [ + ...columns.map((column) => + column.key === "status" + ? { + ...column, + render: (room) => ( + page.openStatus(room)} /> + ) + } + : column + ), + { + key: "actions", + label: "操作", + width: "minmax(104px, 0.6fr)", + render: (room) => + } + ]; + + return ( +
+
+
+
+ + + +
+
+ + +
+ room.roomId} /> + {total > 0 ? : null} +
+
+
+ + + page.setForm({ ...page.form, title: event.target.value })} /> + page.setForm({ ...page.form, coverUrl })} /> + page.setForm({ ...page.form, visibleRegionId })} /> + page.setForm({ ...page.form, description: event.target.value })} /> + + + + page.setStatusForm({ status: event.target.value })}> + 正常 + 关闭 + + +
+ ); +} + +function RoomIdentity({ room }) { + return ( +
+ + {room.coverUrl ? : } + +
+
{room.title || "-"}
+
{room.roomId}
+
+
+ ); +} + +function OwnerIdentity({ room }) { + const owner = room.owner || {}; + const ownerName = owner.username || "-"; + const ownerDisplayId = owner.displayUserId || room.ownerUserId || "-"; + + return ( +
+ + {owner.avatar ? : } + +
+
{ownerName}
+
{ownerDisplayId}
+
+
+ ); +} + +function RoomActions({ page, room }) { + return ( +
+ {page.abilities.canUpdate ? ( + + + page.openEdit(room)}> + + + + + ) : null} + {page.abilities.canDelete ? ( + + + page.removeRoom(room)}> + + + + + ) : null} +
+ ); +} + +function RoomStatus({ disabled, onClick, status }) { + const normalizedStatus = status === "active" ? "active" : "closed"; + const tone = normalizedStatus === "active" ? "running" : "stopped"; + const badge = ( + + + {roomStatusLabels[normalizedStatus]} + + ); + if (disabled) { + return badge; + } + return ( + + ); +} + +function ActionModal({ children, disabled, loading, onClose, onSubmit, open, title }) { + return ( + + {children} + + ); +} + +function formatNumber(value) { + return Number(value || 0).toLocaleString("zh-CN"); +} diff --git a/src/features/rooms/permissions.js b/src/features/rooms/permissions.js new file mode 100644 index 0000000..f0da236 --- /dev/null +++ b/src/features/rooms/permissions.js @@ -0,0 +1,13 @@ +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { PERMISSIONS } from "@/app/permissions"; + +export function useRoomAbilities() { + const { can } = useAuth(); + + return { + canDelete: can(PERMISSIONS.roomDelete), + canUpdate: can(PERMISSIONS.roomUpdate), + canUpload: can(PERMISSIONS.uploadCreate), + canView: can(PERMISSIONS.roomView) + }; +} diff --git a/src/features/rooms/rooms.module.css b/src/features/rooms/rooms.module.css new file mode 100644 index 0000000..9ea2468 --- /dev/null +++ b/src/features/rooms/rooms.module.css @@ -0,0 +1,186 @@ +.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; + 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; +} + +.regionInput { + 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); +} + +.cover { + 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: var(--radius-control); + background: var(--bg-card-strong); + color: var(--text-secondary); +} + +.cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.ownerAvatar { + 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); +} + +.ownerAvatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.identityText { + min-width: 0; +} + +.name { + overflow: hidden; + color: var(--text-primary); + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.meta { + color: var(--text-tertiary); + font-size: var(--admin-font-size); +} + +.stack { + display: grid; + gap: 2px; +} + +.primaryText { + color: var(--text-primary); + font-weight: 650; +} + +.rowActions { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.statusButton { + display: inline-flex; + align-items: center; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; +} + +.statusButton:focus-visible { + border-radius: 999px; + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +.statusButton:hover :global(.status-badge) { + border-color: var(--primary-border); +} + +@media (max-width: 760px) { + .toolbar, + .filters { + align-items: stretch; + flex-direction: column; + } + + .search, + .statusSelect, + .regionInput { + flex: 1 1 auto; + width: 100%; + } +} diff --git a/src/features/rooms/routes.js b/src/features/rooms/routes.js new file mode 100644 index 0000000..64cd24f --- /dev/null +++ b/src/features/rooms/routes.js @@ -0,0 +1,12 @@ +import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; + +export const roomRoutes = [ + { + label: "房间列表", + loader: () => import("./pages/RoomListPage.jsx").then((module) => module.RoomListPage), + menuCode: MENU_CODES.roomList, + pageKey: "room-list", + path: "/rooms", + permission: PERMISSIONS.roomView + } +]; diff --git a/src/features/rooms/schema.ts b/src/features/rooms/schema.ts new file mode 100644 index 0000000..7da664a --- /dev/null +++ b/src/features/rooms/schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +const optionalText = (max: number, message: string) => z.string().trim().max(max, message).optional().default(""); + +export const roomUpdateSchema = z.object({ + coverUrl: optionalText(512, "房间头像不能超过 512 个字符"), + description: optionalText(512, "房间简介不能超过 512 个字符"), + title: z.string().trim().min(1, "请输入房间名称").max(128, "房间名称不能超过 128 个字符"), + visibleRegionId: z.coerce.number().int().min(0, "区域 ID 不正确").default(0) +}); + +export const roomStatusSchema = z.object({ + status: z.string().trim().refine((value) => value === "active" || value === "closed", "房间状态不正确") +}); + +export type RoomUpdateForm = z.infer; +export type RoomStatusForm = z.infer; diff --git a/src/features/search/api.ts b/src/features/search/api.ts new file mode 100644 index 0000000..0d981ac --- /dev/null +++ b/src/features/search/api.ts @@ -0,0 +1,7 @@ +import { apiRequest } from "@/shared/api/request"; +import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import type { SearchResultDto } from "@/shared/api/types"; + +export function searchGlobal(keyword: string): Promise { + return apiRequest(apiEndpointPath(API_OPERATIONS.search), { query: { keyword } }); +} diff --git a/src/components/search/SearchDialog.jsx b/src/features/search/components/SearchDialog.jsx similarity index 91% rename from src/components/search/SearchDialog.jsx rename to src/features/search/components/SearchDialog.jsx index 6afad7c..9e77e53 100644 --- a/src/components/search/SearchDialog.jsx +++ b/src/features/search/components/SearchDialog.jsx @@ -3,8 +3,8 @@ import List from "@mui/material/List"; import ListItemButton from "@mui/material/ListItemButton"; import ListItemText from "@mui/material/ListItemText"; import { useEffect, useState } from "react"; -import { searchGlobal } from "../../api/search.js"; -import { SearchBox } from "../base/SearchBox.jsx"; +import { searchGlobal } from "@/features/search/api"; +import { SearchBox } from "@/shared/ui/SearchBox.jsx"; export function SearchDialog({ onClose, onSelect, open }) { const [keyword, setKeyword] = useState(""); @@ -53,7 +53,7 @@ export function SearchDialog({ onClose, onSelect, open }) { slotProps={{ paper: { className: "search-dialog" } }} >
- +
diff --git a/src/features/users/api.test.ts b/src/features/users/api.test.ts new file mode 100644 index 0000000..52c7d2f --- /dev/null +++ b/src/features/users/api.test.ts @@ -0,0 +1,39 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { setAccessToken } from "@/shared/api/request"; +import { listUsers, updateUserStatus } from "./api"; + +afterEach(() => { + setAccessToken(""); + vi.unstubAllGlobals(); +}); + +test("listUsers uses generated endpoint path and query", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 10, total: 0 } }))) + ); + + const result = await listUsers({ page: 1, page_size: 10, status: "active" }); + const [url, init] = vi.mocked(fetch).mock.calls[0]; + + expect(result.items).toEqual([]); + expect(String(url)).toContain("/api/v1/users?"); + expect(String(url)).toContain("page=1"); + expect(String(url)).toContain("page_size=10"); + expect(String(url)).toContain("status=active"); + expect(init?.method).toBe("GET"); +}); + +test("updateUserStatus sends generated endpoint path and payload", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { id: 7, name: "张三", account: "zhangsan", status: "locked" } }))) + ); + + await updateUserStatus(7, "locked"); + const [url, init] = vi.mocked(fetch).mock.calls[0]; + + expect(String(url)).toContain("/api/v1/users/7/status"); + expect(init?.method).toBe("PATCH"); + expect(JSON.parse(String(init?.body))).toEqual({ status: "locked" }); +}); diff --git a/src/features/users/api.ts b/src/features/users/api.ts new file mode 100644 index 0000000..d38b734 --- /dev/null +++ b/src/features/users/api.ts @@ -0,0 +1,65 @@ +import { apiRequest } from "@/shared/api/request"; +import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import type { + AdminUserDto, + ApiPage, + CreateUserResultDto, + EntityId, + PageQuery, + ResetPasswordResultDto, + UserFormPayload, + UserStatus, + UserUpdatePayload +} from "@/shared/api/types"; + +export function listUsers(query?: PageQuery): Promise> { + return apiRequest>(apiEndpointPath(API_OPERATIONS.listUsers), { query }); +} + +export function getUser(id: EntityId): Promise { + return apiRequest(apiEndpointPath(API_OPERATIONS.getUser, { id })); +} + +export function createUser(payload: UserFormPayload): Promise { + const endpoint = API_ENDPOINTS.createUser; + return apiRequest(apiEndpointPath(API_OPERATIONS.createUser), { + body: payload, + method: endpoint.method + }); +} + +export function updateUser(id: EntityId, payload: UserUpdatePayload): Promise { + const endpoint = API_ENDPOINTS.updateUser; + return apiRequest(apiEndpointPath(API_OPERATIONS.updateUser, { id }), { + body: payload, + method: endpoint.method + }); +} + +export function updateUserStatus(id: EntityId, status: UserStatus): Promise { + const endpoint = API_ENDPOINTS.updateUserStatus; + return apiRequest(apiEndpointPath(API_OPERATIONS.updateUserStatus, { id }), { + body: { status }, + method: endpoint.method + }); +} + +export function resetUserPassword(id: EntityId, password?: string): Promise { + const endpoint = API_ENDPOINTS.resetUserPassword; + return apiRequest(apiEndpointPath(API_OPERATIONS.resetUserPassword, { id }), { + body: { password }, + method: endpoint.method + }); +} + +export function batchUpdateUserStatus(ids: EntityId[], status: UserStatus): Promise { + const endpoint = API_ENDPOINTS.batchUpdateUserStatus; + return apiRequest(apiEndpointPath(API_OPERATIONS.batchUpdateUserStatus), { + body: { ids, status }, + method: endpoint.method + }); +} + +export function exportUsers(query?: PageQuery): Promise { + return apiRequest(apiEndpointPath(API_OPERATIONS.exportUsers), { query, raw: true }); +} diff --git a/src/features/users/components/UserFilters.jsx b/src/features/users/components/UserFilters.jsx new file mode 100644 index 0000000..cb06db2 --- /dev/null +++ b/src/features/users/components/UserFilters.jsx @@ -0,0 +1,44 @@ +import { Button } from "@/shared/ui/Button.jsx"; +import { FilterChip } from "@/shared/ui/FilterChip.jsx"; +import { SearchBox } from "@/shared/ui/SearchBox.jsx"; + +const statusFilters = [ + ["all", "全部"], + ["active", "启用"], + ["locked", "锁定"], + ["disabled", "停用"] +]; + +export function UserFilters({ + canBatchStatus, + onBatchStatus, + onQueryChange, + onRoleFilterChange, + onStatusFilterChange, + query, + roleFilter, + roleFilters, + statusFilter +}) { + return ( +
+ +
+ {roleFilters.map(([value, label]) => ( + onRoleFilterChange(value)} /> + ))} +
+
+ {statusFilters.map(([value, label]) => ( + onStatusFilterChange(value)} /> + ))} +
+ {canBatchStatus ? ( +
+ + +
+ ) : null} +
+ ); +} diff --git a/src/features/users/components/UserFormDrawer.jsx b/src/features/users/components/UserFormDrawer.jsx new file mode 100644 index 0000000..22b6dd2 --- /dev/null +++ b/src/features/users/components/UserFormDrawer.jsx @@ -0,0 +1,57 @@ +import Drawer from "@mui/material/Drawer"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Switch from "@mui/material/Switch"; +import TextField from "@mui/material/TextField"; +import { Button } from "@/shared/ui/Button.jsx"; + +export function UserFormDrawer({ + editingUser, + form, + onClose, + onRoleChecked, + onSubmit, + open, + roles, + setForm +}) { + return ( + +
+

{editingUser ? "编辑用户" : "新增用户"}

+ setForm({ ...form, username: event.target.value })} + /> + setForm({ ...form, name: event.target.value })} /> + setForm({ ...form, team: event.target.value })} /> + {!editingUser ? ( + setForm({ ...form, password: event.target.value })} /> + ) : null} +
角色
+
+ {roles.map((role) => ( + + ))} +
+ setForm({ ...form, mfaEnabled: event.target.checked })} />} + label={form.mfaEnabled ? "MFA 已开启" : "MFA 未开启"} + /> +
+ + +
+ +
+ ); +} diff --git a/src/features/users/components/UserProfileDrawer.jsx b/src/features/users/components/UserProfileDrawer.jsx new file mode 100644 index 0000000..ff97fd9 --- /dev/null +++ b/src/features/users/components/UserProfileDrawer.jsx @@ -0,0 +1,50 @@ +import Person from "@mui/icons-material/Person"; +import { SideDrawer } from "@/shared/ui/SideDrawer.jsx"; +import { UserStatusBadge } from "./UserStatusBadge.jsx"; + +export function UserProfileDrawer({ onClose, open, user }) { + if (!user) { + return null; + } + + return ( + +
+
+
+ +
+
+ {user.name || "-"} + {user.account || "-"} +
+
+ +
+ + + + } /> + + +
+
+
+ ); +} + +function ProfileRow({ label, value }) { + return ( +
+ {label} +
{value === 0 || value ? value : "-"}
+
+ ); +} + +function formatMfa(user) { + if (user.mfa) { + return user.mfa; + } + return user.mfaEnabled ? "已开启" : "未开启"; +} diff --git a/src/features/users/components/UserStats.jsx b/src/features/users/components/UserStats.jsx new file mode 100644 index 0000000..9823fbb --- /dev/null +++ b/src/features/users/components/UserStats.jsx @@ -0,0 +1,17 @@ +import BlockOutlined from "@mui/icons-material/BlockOutlined"; +import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined"; +import ShieldOutlined from "@mui/icons-material/ShieldOutlined"; +import VerifiedUserOutlined from "@mui/icons-material/VerifiedUserOutlined"; +import { KpiCard } from "@/shared/ui/KpiCard.jsx"; +import { userStatusMeta } from "@/features/users/constants.js"; + +export function UserStats({ stats, total }) { + return ( +
+ {stats.active} 个启用账户} /> + 当前页统计} /> + 可登录控制台} /> + 需管理员处理} /> +
+ ); +} diff --git a/src/components/user/UserStatusBadge.jsx b/src/features/users/components/UserStatusBadge.jsx similarity index 84% rename from src/components/user/UserStatusBadge.jsx rename to src/features/users/components/UserStatusBadge.jsx index e78449e..0d97c46 100644 --- a/src/components/user/UserStatusBadge.jsx +++ b/src/features/users/components/UserStatusBadge.jsx @@ -1,5 +1,5 @@ import Chip from "@mui/material/Chip"; -import { userStatusMeta } from "../../data/users.js"; +import { userStatusMeta } from "@/features/users/constants.js"; export function UserStatusBadge({ status }) { const meta = userStatusMeta[status]; diff --git a/src/features/users/components/UserTable.jsx b/src/features/users/components/UserTable.jsx new file mode 100644 index 0000000..41bc7cb --- /dev/null +++ b/src/features/users/components/UserTable.jsx @@ -0,0 +1,146 @@ +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 { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { UserStatusBadge } from "./UserStatusBadge.jsx"; + +export function UserTable({ + canEdit, + canResetPassword, + canStatus, + onEdit, + onOpenProfile = () => {}, + onResetPassword, + onToggleDisabled, + onToggleLocked, + selectedIds, + setSelectedIds, + 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)); + }; + const columns = [ + { + key: "select", + header: toggleAll(event.target.checked)} size="small" />, + width: "48px", + render: (user) => ( + { + setSelectedIds((current) => (event.target.checked ? [...current, user.id] : current.filter((id) => id !== user.id))); + }} + size="small" + /> + ) + }, + { + key: "user", + label: "用户", + width: "minmax(180px, 1.4fr)", + render: (user) => ( +
+ +
+
{user.name}
+
{user.account}
+
+
+ ) + }, + { key: "role", label: "角色", width: "minmax(120px, 1fr)", render: (user) => user.role || "-" }, + { key: "team", label: "团队", width: "minmax(120px, 1fr)", render: (user) => user.team || "-" }, + { key: "status", label: "状态", width: "minmax(96px, 0.72fr)", render: (user) => }, + { key: "mfa", label: "MFA", width: "minmax(80px, 0.72fr)" }, + { key: "lastLogin", label: "最后登录", width: "minmax(120px, 0.9fr)", className: "muted" }, + { + key: "actions", + label: "操作", + width: "minmax(180px, 1.2fr)", + render: (user) => ( + + ) + } + ]; + + return ( + user.id} + /> + ); +} + +function UserRowActions({ + canEdit, + canResetPassword, + canStatus, + onEdit, + onResetPassword, + onToggleDisabled, + onToggleLocked, + user +}) { + const isDisabled = user.status === "disabled"; + const isLocked = user.status === "locked"; + + return ( + + onEdit(user)}> + + + onResetPassword(user)}> + + + onToggleLocked(user)} + tone={isLocked ? "success" : undefined} + > + {isLocked ? : } + + onToggleDisabled(user)} + tone={isDisabled ? "success" : "danger"} + > + {isDisabled ? : } + + + ); +} diff --git a/src/features/users/components/UserTable.test.jsx b/src/features/users/components/UserTable.test.jsx new file mode 100644 index 0000000..7abeb72 --- /dev/null +++ b/src/features/users/components/UserTable.test.jsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { expect, test, vi } from "vitest"; +import { UserTable } from "./UserTable.jsx"; + +const baseUser = { + account: "alice", + id: 1, + lastLogin: "2026-05-01 10:00", + mfa: "已开启", + name: "Alice", + role: "管理员", + status: "active", + team: "平台" +}; + +test("opens user profile from avatar", async () => { + const onOpenProfile = vi.fn(); + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByRole("button", { name: "查看 Alice 信息" })); + + expect(onOpenProfile).toHaveBeenCalledWith(baseUser); +}); diff --git a/src/features/users/components/UserToolbar.jsx b/src/features/users/components/UserToolbar.jsx new file mode 100644 index 0000000..1293c43 --- /dev/null +++ b/src/features/users/components/UserToolbar.jsx @@ -0,0 +1,30 @@ +import Add from "@mui/icons-material/Add"; +import DownloadOutlined from "@mui/icons-material/DownloadOutlined"; +import ShieldOutlined from "@mui/icons-material/ShieldOutlined"; +import { Button } from "@/shared/ui/Button.jsx"; +import { PageHead } from "@/shared/ui/PageHead.jsx"; + +export function UserToolbar({ abilities, onCreate, onExport, onOpenRoles }) { + return ( + + {abilities.canViewRoles ? ( + + ) : null} + {abilities.canExport ? ( + + ) : null} + {abilities.canCreate ? ( + + ) : null} + + ); +} diff --git a/src/features/users/components/UserToolbar.test.jsx b/src/features/users/components/UserToolbar.test.jsx new file mode 100644 index 0000000..61ef12d --- /dev/null +++ b/src/features/users/components/UserToolbar.test.jsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { UserToolbar } from "./UserToolbar.jsx"; + +const handlers = { + onCreate: vi.fn(), + onExport: vi.fn(), + onOpenRoles: vi.fn() +}; + +test("hides protected user actions without abilities", () => { + render( + + ); + + expect(screen.queryByRole("button", { name: /新增用户/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /导出/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /角色配置/ })).not.toBeInTheDocument(); +}); + +test("shows user actions with matching abilities", () => { + render( + + ); + + expect(screen.getByRole("button", { name: /新增用户/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /导出/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /角色配置/ })).toBeInTheDocument(); +}); diff --git a/src/data/users.js b/src/features/users/constants.js similarity index 89% rename from src/data/users.js rename to src/features/users/constants.js index 6954d53..af1a271 100644 --- a/src/data/users.js +++ b/src/features/users/constants.js @@ -4,6 +4,13 @@ export const userStatusMeta = { disabled: { label: "停用", tone: "stopped" } }; +export const userStatusFilters = [ + ["", "全部状态"], + ["active", "启用"], + ["locked", "锁定"], + ["disabled", "停用"] +]; + export const initialUsers = [ { id: "u-001", diff --git a/src/features/users/hooks/useUsersPage.js b/src/features/users/hooks/useUsersPage.js new file mode 100644 index 0000000..636fcd4 --- /dev/null +++ b/src/features/users/hooks/useUsersPage.js @@ -0,0 +1,268 @@ +import { useCallback, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { downloadCsv } from "@/shared/api/download"; +import { toPageQuery } from "@/shared/api/query"; +import { parseForm } from "@/shared/forms/validation"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { listRoles } from "@/features/roles/api"; +import { + batchUpdateUserStatus, + createUser, + exportUsers, + listUsers, + resetUserPassword, + updateUser, + updateUserStatus +} from "@/features/users/api"; +import { useUserAbilities } from "@/features/users/permissions.js"; +import { userCreateFormSchema, userUpdateFormSchema } from "@/features/users/schema"; + +const pageSize = 10; +const emptyUsers = { items: [], total: 0, page: 1, pageSize }; +const emptyData = { roles: [], users: emptyUsers }; + +const emptyForm = { + mfaEnabled: false, + name: "", + password: "", + roleIds: [], + status: "active", + team: "", + username: "" +}; + +export function useUsersPage() { + const [query, setQuery] = useState(""); + const [roleFilter, setRoleFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [page, setPage] = useState(1); + const [selectedIds, setSelectedIds] = useState([]); + const [formOpen, setFormOpen] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [profileUser, setProfileUser] = useState(null); + const [form, setForm] = useState(emptyForm); + const abilities = useUserAbilities(); + const confirm = useConfirm(); + const { showToast } = useToast(); + const navigate = useNavigate(); + + const requestQuery = useMemo( + () => toPageQuery({ keyword: query, page, pageSize, role: roleFilter, status: statusFilter }), + [page, query, roleFilter, statusFilter] + ); + + const queryFn = useCallback(async () => { + const [userData, roleData] = await Promise.all([ + listUsers(requestQuery), + abilities.canViewRoles ? listRoles() : [] + ]); + setSelectedIds([]); + return { roles: roleData || [], users: userData || emptyUsers }; + }, [abilities.canViewRoles, requestQuery]); + + const { data, error, loading, reload } = useAdminQuery(queryFn, { + errorMessage: "加载用户失败", + initialData: emptyData, + keepPreviousData: true, + queryKey: ["users", requestQuery, abilities.canViewRoles] + }); + + const users = data?.users || emptyUsers; + const roles = useMemo(() => data?.roles || [], [data?.roles]); + + const roleFilters = useMemo(() => [["", "全部角色"], ...roles.map((role) => [role.name, role.name])], [roles]); + + const stats = useMemo(() => { + const items = users.items || []; + const active = items.filter((user) => user.status === "active").length; + const locked = items.filter((user) => user.status === "locked").length; + const admin = items.filter((user) => String(user.role).includes("管理员")).length; + return { active, admin, locked }; + }, [users.items]); + + const changeQuery = (value) => { + setQuery(value); + setPage(1); + }; + + const changeRoleFilter = (value) => { + setRoleFilter(value); + setPage(1); + }; + + const changeStatusFilter = (value) => { + setStatusFilter(value); + setPage(1); + }; + + const openCreate = () => { + setEditingUser(null); + setForm(emptyForm); + setFormOpen(true); + }; + + const openEdit = (user) => { + setEditingUser(user); + setForm({ + mfaEnabled: user.mfaEnabled, + name: user.name, + password: "", + roleIds: user.roleIds || [], + status: user.status, + team: user.team || "", + username: user.account + }); + setFormOpen(true); + }; + + const closeForm = () => { + setFormOpen(false); + }; + + const openProfile = (user) => { + setProfileUser(user); + }; + + const closeProfile = () => { + setProfileUser(null); + }; + + const submitUser = async (event) => { + event.preventDefault(); + try { + if (editingUser) { + const payload = parseForm(userUpdateFormSchema, form); + await updateUser(editingUser.id, payload); + showToast("用户已更新", "success"); + } else { + const payload = parseForm(userCreateFormSchema, form); + const result = await createUser(payload); + showToast(`用户已创建,初始密码:${result.initialPassword}`, "success"); + } + setFormOpen(false); + reload(); + } catch (err) { + showToast(err.message || "保存用户失败", "error"); + } + }; + + const toggleLocked = async (user) => { + const nextStatus = user.status === "locked" ? "active" : "locked"; + const ok = await confirm({ + confirmText: user.status === "locked" ? "解锁" : "锁定", + message: `${user.name} 的登录状态会立即变更。`, + title: user.status === "locked" ? "解锁用户" : "锁定用户", + tone: user.status === "locked" ? "primary" : "danger" + }); + if (!ok) { + return; + } + await updateUserStatus(user.id, nextStatus); + showToast("用户状态已更新", "success"); + reload(); + }; + + const toggleDisabled = async (user) => { + const nextStatus = user.status === "disabled" ? "active" : "disabled"; + const ok = await confirm({ + confirmText: user.status === "disabled" ? "启用" : "停用", + message: `${user.name} 的账号状态会立即变更。`, + title: user.status === "disabled" ? "启用用户" : "停用用户", + tone: user.status === "disabled" ? "primary" : "danger" + }); + if (!ok) { + return; + } + await updateUserStatus(user.id, nextStatus); + showToast("用户状态已更新", "success"); + reload(); + }; + + const resetPassword = async (user) => { + const ok = await confirm({ + confirmText: "重置", + message: `${user.name} 的密码会重置为系统生成的初始密码。`, + title: "重置密码", + tone: "danger" + }); + if (!ok) { + return; + } + const result = await resetUserPassword(user.id); + showToast(`已重置,初始密码:${result.initialPassword}`, "success"); + }; + + const batchStatus = async (status) => { + if (!selectedIds.length) { + showToast("请先选择用户", "warning"); + return; + } + const ok = await confirm({ + confirmText: "确认", + message: `将更新 ${selectedIds.length} 个用户状态。`, + title: "批量更新状态", + tone: status === "active" ? "primary" : "danger" + }); + if (!ok) { + return; + } + await batchUpdateUserStatus(selectedIds, status); + showToast("批量状态已更新", "success"); + reload(); + }; + + const downloadUsers = () => { + return downloadCsv( + () => exportUsers({ keyword: query, role: roleFilter, status: statusFilter }), + "hyapp-users.csv" + ); + }; + + const setRoleChecked = (roleId, checked) => { + setForm((current) => ({ + ...current, + roleIds: checked ? [...current.roleIds, roleId] : current.roleIds.filter((id) => id !== roleId) + })); + }; + + return { + abilities, + batchStatus, + changeQuery, + changeRoleFilter, + changeStatusFilter, + closeForm, + closeProfile, + downloadUsers, + editingUser, + error, + form, + formOpen, + loading, + navigate, + openCreate, + openEdit, + openProfile, + page, + profileUser, + query, + reload, + resetPassword, + roleFilter, + roleFilters, + roles, + selectedIds, + setForm, + setPage, + setRoleChecked, + setSelectedIds, + stats, + statusFilter, + submitUser, + toggleDisabled, + toggleLocked, + users + }; +} diff --git a/src/features/users/pages/UserManagementPage.jsx b/src/features/users/pages/UserManagementPage.jsx new file mode 100644 index 0000000..871e34b --- /dev/null +++ b/src/features/users/pages/UserManagementPage.jsx @@ -0,0 +1,104 @@ +import Add from "@mui/icons-material/Add"; +import DownloadOutlined from "@mui/icons-material/DownloadOutlined"; +import PersonRemoveOutlined from "@mui/icons-material/PersonRemoveOutlined"; +import ShieldOutlined from "@mui/icons-material/ShieldOutlined"; +import VerifiedUserOutlined from "@mui/icons-material/VerifiedUserOutlined"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; +import { + AdminActionIconButton, + AdminFilterSelect, + AdminListBody, + AdminListPage, + AdminListToolbar, + AdminSearchBox +} from "@/shared/ui/AdminListLayout.jsx"; +import { UserFormDrawer } from "@/features/users/components/UserFormDrawer.jsx"; +import { UserProfileDrawer } from "@/features/users/components/UserProfileDrawer.jsx"; +import { UserTable } from "@/features/users/components/UserTable.jsx"; +import { useUsersPage } from "@/features/users/hooks/useUsersPage.js"; +import { userStatusFilters } from "@/features/users/constants.js"; + +export function UserManagementPage() { + const page = useUsersPage(); + + return ( + <> + + + + + + + )} + actions={( + <> + {page.abilities.canViewRoles ? ( + page.navigate("/system/roles")}> + + + ) : null} + {page.abilities.canStatus ? ( + <> + page.batchStatus("active")}> + + + page.batchStatus("disabled")}> + + + + ) : null} + {page.abilities.canExport ? ( + + + + ) : null} + {page.abilities.canCreate ? ( + + + + ) : null} + + )} + /> + + + + + + + + + + + + + ); +} diff --git a/src/features/users/permissions.js b/src/features/users/permissions.js new file mode 100644 index 0000000..c194461 --- /dev/null +++ b/src/features/users/permissions.js @@ -0,0 +1,15 @@ +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { PERMISSIONS } from "@/app/permissions"; + +export function useUserAbilities() { + const { can } = useAuth(); + + return { + canCreate: can(PERMISSIONS.userCreate), + canEdit: can(PERMISSIONS.userUpdate), + canExport: can(PERMISSIONS.userExport), + canResetPassword: can(PERMISSIONS.userResetPassword), + canStatus: can(PERMISSIONS.userStatus), + canViewRoles: can(PERMISSIONS.roleView) + }; +} diff --git a/src/features/users/routes.js b/src/features/users/routes.js new file mode 100644 index 0000000..1a56fdd --- /dev/null +++ b/src/features/users/routes.js @@ -0,0 +1,12 @@ +import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; + +export const usersRoutes = [ + { + label: "用户管理", + loader: () => import("./pages/UserManagementPage.jsx").then((module) => module.UserManagementPage), + menuCode: MENU_CODES.systemUsers, + pageKey: "users", + path: "/system/users", + permission: PERMISSIONS.userView + } +]; diff --git a/src/features/users/schema.test.ts b/src/features/users/schema.test.ts new file mode 100644 index 0000000..38b30a5 --- /dev/null +++ b/src/features/users/schema.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; +import { FormValidationError, parseForm } from "@/shared/forms/validation"; +import { userCreateFormSchema, userUpdateFormSchema } from "./schema"; + +describe("user form schema", () => { + test("normalizes create payload", () => { + const payload = parseForm(userCreateFormSchema, { + mfaEnabled: true, + name: " 张三 ", + password: "", + roleIds: ["1", 2], + status: "active", + team: " 平台组 ", + username: " admin " + }); + + expect(payload).toEqual({ + mfaEnabled: true, + name: "张三", + password: "", + roleIds: [1, 2], + status: "active", + team: "平台组", + username: "admin" + }); + }); + + test("rejects missing required fields", () => { + expect(() => + parseForm(userUpdateFormSchema, { + mfaEnabled: false, + name: "", + roleIds: [], + status: "active", + team: "" + }) + ).toThrow(FormValidationError); + }); +}); diff --git a/src/features/users/schema.ts b/src/features/users/schema.ts new file mode 100644 index 0000000..f5970b9 --- /dev/null +++ b/src/features/users/schema.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +const userStatusSchema = z.enum(["active", "disabled", "locked"]); +const roleIdsSchema = z.array(z.coerce.number().int().positive()).default([]); + +const userBaseSchema = z.object({ + mfaEnabled: z.boolean().default(false), + name: z.string().trim().min(1, "请输入姓名").max(50, "姓名不能超过 50 个字符"), + roleIds: roleIdsSchema, + status: userStatusSchema, + team: z.string().trim().max(80, "团队不能超过 80 个字符").optional().default("") +}); + +export const userCreateFormSchema = userBaseSchema.extend({ + password: z.string().trim().max(72, "密码不能超过 72 个字符").optional().default(""), + username: z.string().trim().min(1, "请输入账号").max(50, "账号不能超过 50 个字符") +}); + +export const userUpdateFormSchema = userBaseSchema; + +export type UserCreateForm = z.infer; +export type UserUpdateForm = z.infer; diff --git a/src/features/users/users.css b/src/features/users/users.css new file mode 100644 index 0000000..543d642 --- /dev/null +++ b/src/features/users/users.css @@ -0,0 +1,121 @@ +.user-kpi-row { + display: grid; + grid-template-columns: repeat(4, minmax(170px, 1fr)); + gap: var(--space-4); +} + +.user-filters-panel { + flex-wrap: wrap; +} + +.user-filters-panel .search-box { + flex: 0 0 320px; +} + +.user-main { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-3); +} + +.user-avatar { + display: grid; + flex: 0 0 auto; + width: 34px; + height: 34px; + place-items: center; + border-radius: var(--radius-control); + background: var(--primary-surface); + color: var(--primary); + transition: + background var(--motion-base) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.user-avatar-button { + display: grid; + flex: 0 0 auto; + width: 34px; + height: 34px; + padding: 0; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: inherit; + cursor: pointer; +} + +.user-avatar-button:focus-visible { + outline: 2px solid var(--primary-border-strong); + outline-offset: 2px; +} + +.admin-row:hover .user-avatar { + background: var(--primary-surface-strong); + transform: scale(1.08); +} + +.user-profile { + display: grid; + gap: var(--space-5); +} + +.user-profile__head { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.user-profile__avatar { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border-radius: var(--radius-control); + background: var(--primary-surface); + color: var(--primary); +} + +.user-profile__identity { + display: grid; + min-width: 0; + gap: var(--space-1); +} + +.user-profile__identity strong { + color: var(--text-primary); + font-weight: 800; +} + +.user-profile__identity span { + overflow: hidden; + color: var(--text-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-profile__rows { + display: grid; + border-top: 1px solid var(--border); +} + +.user-profile__row { + display: grid; + grid-template-columns: minmax(88px, 0.45fr) minmax(0, 1fr); + gap: var(--space-3); + align-items: center; + min-height: 44px; + border-bottom: 1px solid var(--border); +} + +.user-profile__row span { + color: var(--text-tertiary); +} + +.user-profile__value { + min-width: 0; + color: var(--text-primary); + font-weight: 700; + overflow-wrap: anywhere; +} diff --git a/src/main.jsx b/src/main.jsx index c1df0b9..8e53b3b 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,28 +1,21 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { ThemeProvider } from "@mui/material/styles"; import { BrowserRouter } from "react-router-dom"; -import { AuthProvider } from "./auth/AuthProvider.jsx"; -import { ConfirmProvider } from "./components/base/ConfirmProvider.jsx"; -import { ToastProvider } from "./components/base/ToastProvider.jsx"; -import App from "./App.jsx"; -import { theme } from "./theme.js"; +import App from "@/app/App.jsx"; +import { ErrorBoundary } from "@/app/ErrorBoundary.jsx"; +import { AppProviders } from "@/app/providers.jsx"; import "./styles/tokens.css"; import "./styles/base.css"; import "./styles/app.css"; createRoot(document.getElementById("app")).render( - - - - - - - - - - - + + + + + + + ); diff --git a/src/pages/LogsPage.jsx b/src/pages/LogsPage.jsx deleted file mode 100644 index e37ec6d..0000000 --- a/src/pages/LogsPage.jsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useEffect, useState } from "react"; -import { useOutletContext } from "react-router-dom"; -import { listLoginLogs, listOperationLogs } from "../api/logs.js"; -import { Card } from "../components/base/Card.jsx"; -import { DataState } from "../components/base/DataState.jsx"; -import { PaginationBar } from "../components/base/PaginationBar.jsx"; -import { SearchBox } from "../components/base/SearchBox.jsx"; -import { PageHead } from "../components/layout/PageHead.jsx"; - -export function LogsPage({ type }) { - const [query, setQuery] = useState(""); - const [page, setPage] = useState(1); - const [data, setData] = useState({ items: [], total: 0, page: 1, pageSize: 10 }); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const { refreshTick } = useOutletContext(); - const isLogin = type === "login"; - - const load = async () => { - setLoading(true); - setError(""); - try { - const loader = isLogin ? listLoginLogs : listOperationLogs; - const result = await loader({ page, page_size: 10, keyword: query }); - setData(result); - } catch (err) { - setError(err.message || "加载日志失败"); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - load(); - }, [page, query, refreshTick, type]); - - return ( - <> - -
- { setQuery(value); setPage(1); }} placeholder="搜索用户、IP、动作、资源..." /> -
- - - -
-
{isLogin ? "登录记录" : "操作记录"}
-
共 {data.total} 条
-
-
-
- {isLogin ? ( - <> -
用户
-
IP
-
状态
-
消息
-
时间
- - ) : ( - <> -
用户
-
动作
-
资源
-
状态
-
详情
-
时间
- - )} -
- {(data.items || []).map((item) => ( -
- {isLogin ? ( - <> -
{item.username || "-"}
-
{item.ip || "-"}
-
{item.status}
-
{item.message || "-"}
-
{formatDate(item.createdAt)}
- - ) : ( - <> -
{item.username || "-"}
-
{item.action}
-
{item.resource || "-"}
-
{item.status}
-
{item.detail || "-"}
-
{formatDate(item.createdAt)}
- - )} -
- ))} - {!data.items?.length ?
暂无日志
: null} -
-
- -
- - ); -} - -function formatDate(value) { - if (!value) { - return "-"; - } - return new Date(value).toLocaleString("zh-CN", { hour12: false }); -} diff --git a/src/pages/MenuManagementPage.jsx b/src/pages/MenuManagementPage.jsx deleted file mode 100644 index 904a354..0000000 --- a/src/pages/MenuManagementPage.jsx +++ /dev/null @@ -1,81 +0,0 @@ -import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined"; -import ShieldOutlined from "@mui/icons-material/ShieldOutlined"; -import { useEffect, useMemo, useState } from "react"; -import { useOutletContext } from "react-router-dom"; -import { getMenus } from "../api/navigation.js"; -import { listPermissions } from "../api/roles.js"; -import { Card } from "../components/base/Card.jsx"; -import { DataState } from "../components/base/DataState.jsx"; -import { KpiCard } from "../components/base/KpiCard.jsx"; -import { PageHead } from "../components/layout/PageHead.jsx"; - -export function MenuManagementPage() { - const [menus, setMenus] = useState([]); - const [permissions, setPermissions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const { refreshTick } = useOutletContext(); - - const flatMenus = useMemo(() => flattenMenus(menus), [menus]); - - const load = async () => { - setLoading(true); - setError(""); - try { - const [menuData, permissionData] = await Promise.all([getMenus(), listPermissions()]); - setMenus(menuData || []); - setPermissions(permissionData || []); - } catch (err) { - setError(err.message || "加载菜单权限失败"); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - load(); - }, [refreshTick]); - - return ( - <> - - - -
- 来自后端菜单树} /> - 菜单 / 按钮 / 接口} /> -
- - -
-
菜单树
-
共 {flatMenus.length} 项
-
-
-
-
菜单
-
路由
-
权限码
-
图标
-
- {flatMenus.map((menu) => ( -
-
{menu.label}
-
{menu.path || "-"}
-
{menu.permissionCode || "-"}
-
{menu.icon || "-"}
-
- ))} -
-
-
- - ); -} - -function flattenMenus(menus, depth = 0) { - return menus.flatMap((menu) => [ - { ...menu, depth }, - ...flattenMenus(menu.children || [], depth + 1) - ]); -} diff --git a/src/pages/NotificationsPage.jsx b/src/pages/NotificationsPage.jsx deleted file mode 100644 index 2ce1a31..0000000 --- a/src/pages/NotificationsPage.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import DoneAllOutlined from "@mui/icons-material/DoneAllOutlined"; -import { useEffect, useState } from "react"; -import { useOutletContext } from "react-router-dom"; -import { listNotifications, markNotificationRead } from "../api/notifications.js"; -import { Button } from "../components/base/Button.jsx"; -import { Card } from "../components/base/Card.jsx"; -import { DataState } from "../components/base/DataState.jsx"; -import { useToast } from "../components/base/ToastProvider.jsx"; -import { PageHead } from "../components/layout/PageHead.jsx"; - -export function NotificationsPage() { - const [data, setData] = useState({ items: [], unread: 0 }); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const { refreshTick } = useOutletContext(); - const { showToast } = useToast(); - - const load = async () => { - setLoading(true); - setError(""); - try { - setData(await listNotifications()); - } catch (err) { - setError(err.message || "加载通知失败"); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - load(); - }, [refreshTick]); - - const read = async (id) => { - await markNotificationRead(id); - showToast("通知已标记为已读", "success"); - load(); - }; - - return ( - <> - - - -
- {(data.items || []).map((item) => ( -
-
-
{item.title}
-
{item.content}
-
{formatDate(item.createdAt)}
-
- {!item.readAt ? ( - - ) : ( - 已读 - )} -
- ))} - {!data.items?.length ?
暂无通知
: null} -
-
-
- - ); -} - -function formatDate(value) { - if (!value) { - return "-"; - } - return new Date(value).toLocaleString("zh-CN", { hour12: false }); -} diff --git a/src/pages/OverviewPage.jsx b/src/pages/OverviewPage.jsx deleted file mode 100644 index 7f36ea9..0000000 --- a/src/pages/OverviewPage.jsx +++ /dev/null @@ -1,173 +0,0 @@ -import Add from "@mui/icons-material/Add"; -import DeveloperBoardOutlined from "@mui/icons-material/DeveloperBoardOutlined"; -import DnsOutlined from "@mui/icons-material/DnsOutlined"; -import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined"; -import Refresh from "@mui/icons-material/Refresh"; -import SecurityOutlined from "@mui/icons-material/SecurityOutlined"; -import StorageOutlined from "@mui/icons-material/StorageOutlined"; -import { useEffect, useMemo, useState } from "react"; -import { useOutletContext } from "react-router-dom"; -import { getDashboardOverview } from "../api/dashboard.js"; -import { listServices, restartService as restartServiceApi, updateServiceStatus } from "../api/services.js"; -import { useAuth } from "../auth/AuthProvider.jsx"; -import { Button } from "../components/base/Button.jsx"; -import { Card } from "../components/base/Card.jsx"; -import { DataState } from "../components/base/DataState.jsx"; -import { KpiCard } from "../components/base/KpiCard.jsx"; -import { TimeRangeSelect } from "../components/base/TimeRangeSelect.jsx"; -import { useConfirm } from "../components/base/ConfirmProvider.jsx"; -import { useToast } from "../components/base/ToastProvider.jsx"; -import { BarChart } from "../components/charts/BarChart.jsx"; -import { ChartCard } from "../components/charts/ChartCard.jsx"; -import { StatusDonut } from "../components/charts/StatusDonut.jsx"; -import { PageHead } from "../components/layout/PageHead.jsx"; -import { ServerRail } from "../components/service/ServerRail.jsx"; -import { ServiceTable } from "../components/service/ServiceTable.jsx"; - -export function OverviewPage() { - const [range, setRange] = useState("近 1 小时"); - const [overview, setOverview] = useState(null); - const [services, setServices] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const { refreshTick } = useOutletContext(); - const { can } = useAuth(); - const confirm = useConfirm(); - const { showToast } = useToast(); - - const load = async () => { - setLoading(true); - setError(""); - try { - const [overviewData, serviceData] = await Promise.all([ - getDashboardOverview(), - listServices({ page: 1, page_size: 8 }) - ]); - setOverview(overviewData); - setServices(serviceData.items || []); - } catch (err) { - setError(err.message || "加载总览失败"); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - load(); - }, [refreshTick]); - - const stats = useMemo(() => { - if (!overview) { - return { running: 0, stopped: 0, warning: 0, error: 0, avgCpu: 0, avgMemory: 0 }; - } - return { - running: overview.running, - stopped: overview.stopped, - warning: overview.warning, - error: overview.error, - avgCpu: overview.avgCpu, - avgMemory: overview.avgMemory - }; - }, [overview]); - - const setServiceStatus = async (id, status) => { - if (status === "stopped") { - const ok = await confirm({ - title: "停止服务", - message: "停止后该服务会立即进入不可用状态。", - confirmText: "停止", - tone: "danger" - }); - if (!ok) { - return; - } - } - await updateServiceStatus(id, status); - showToast("服务状态已更新", "success"); - load(); - }; - - const restartService = async (id) => { - const ok = await confirm({ - title: "重启服务", - message: "重启会写入操作日志并刷新服务状态。", - confirmText: "重启" - }); - if (!ok) { - return; - } - await restartServiceApi(id); - showToast("服务已重启", "success"); - load(); - }; - - return ( - <> - - - - {can("service:create") ? ( - - ) : null} - - - -
- 在线 {overview?.serversOnline || 0}} /> - 运行中 {stats.running} / 已停止 {stats.stopped}} /> - 来自服务资产} /> - 来自服务资产} /> - 严重 {stats.error} / 警告 {stats.warning}} /> -
- -
- -
-
服务器状态分布
-
- -
- - - -
-
告警趋势
-
- -
-
- -
- {}} /> - {}} - restartService={restartService} - setServiceStatus={setServiceStatus} - title="服务列表" - /> -
- -
- {(overview?.alerts || []).map((alert) => ( -
- -
-
{alert.name}
-
{alert.desc}
-
-
{alert.time}
-
- ))} -
-
- - ); -} diff --git a/src/pages/RoleManagementPage.jsx b/src/pages/RoleManagementPage.jsx deleted file mode 100644 index bb2ad99..0000000 --- a/src/pages/RoleManagementPage.jsx +++ /dev/null @@ -1,194 +0,0 @@ -import Add from "@mui/icons-material/Add"; -import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; -import EditOutlined from "@mui/icons-material/EditOutlined"; -import Drawer from "@mui/material/Drawer"; -import TextField from "@mui/material/TextField"; -import { useEffect, useMemo, useState } from "react"; -import { useOutletContext } from "react-router-dom"; -import { createRole, deleteRole, listPermissions, listRoles, updateRole } from "../api/roles.js"; -import { useAuth } from "../auth/AuthProvider.jsx"; -import { Button } from "../components/base/Button.jsx"; -import { Card } from "../components/base/Card.jsx"; -import { DataState } from "../components/base/DataState.jsx"; -import { IconButton } from "../components/base/IconButton.jsx"; -import { useConfirm } from "../components/base/ConfirmProvider.jsx"; -import { useToast } from "../components/base/ToastProvider.jsx"; -import { PageHead } from "../components/layout/PageHead.jsx"; - -const emptyRole = { name: "", code: "", description: "", permissionIds: [] }; - -export function RoleManagementPage() { - const [roles, setRoles] = useState([]); - const [permissions, setPermissions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const [drawerOpen, setDrawerOpen] = useState(false); - const [editingRole, setEditingRole] = useState(null); - const [form, setForm] = useState(emptyRole); - const { refreshTick } = useOutletContext(); - const { can } = useAuth(); - const confirm = useConfirm(); - const { showToast } = useToast(); - - const groupedPermissions = useMemo(() => { - return permissions.reduce((groups, permission) => { - const key = permission.kind || "other"; - groups[key] = groups[key] || []; - groups[key].push(permission); - return groups; - }, {}); - }, [permissions]); - - const load = async () => { - setLoading(true); - setError(""); - try { - const [roleData, permissionData] = await Promise.all([listRoles(), listPermissions()]); - setRoles(roleData || []); - setPermissions(permissionData || []); - } catch (err) { - setError(err.message || "加载角色失败"); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - load(); - }, [refreshTick]); - - const openCreate = () => { - setEditingRole(null); - setForm(emptyRole); - setDrawerOpen(true); - }; - - const openEdit = (role) => { - setEditingRole(role); - setForm({ - name: role.name, - code: role.code, - description: role.description || "", - permissionIds: role.permissions?.map((permission) => permission.id) || [] - }); - setDrawerOpen(true); - }; - - const submit = async (event) => { - event.preventDefault(); - try { - if (editingRole) { - await updateRole(editingRole.id, form); - showToast("角色已更新", "success"); - } else { - await createRole(form); - showToast("角色已创建", "success"); - } - setDrawerOpen(false); - load(); - } catch (err) { - showToast(err.message || "保存角色失败", "error"); - } - }; - - const remove = async (role) => { - const ok = await confirm({ - title: "删除角色", - message: `${role.name} 删除后会影响已绑定用户的权限。`, - confirmText: "删除", - tone: "danger" - }); - if (!ok) { - return; - } - await deleteRole(role.id); - showToast("角色已删除", "success"); - load(); - }; - - const togglePermission = (permissionId, checked) => { - setForm((current) => ({ - ...current, - permissionIds: checked - ? [...current.permissionIds, permissionId] - : current.permissionIds.filter((id) => id !== permissionId) - })); - }; - - return ( - <> - - {can("role:manage") ? ( - - ) : null} - - - - -
-
角色清单
-
共 {roles.length} 条
-
-
-
-
角色
-
编码
-
权限数量
-
说明
-
操作
-
- {roles.map((role) => ( -
-
{role.name}
-
{role.code}
-
{role.permissions?.length || 0}
-
{role.description || "-"}
-
- openEdit(role)}> - - - remove(role)} tone="danger"> - - -
-
- ))} -
-
-
- - setDrawerOpen(false)}> -
-

{editingRole ? "编辑角色" : "新增角色"}

- setForm({ ...form, name: event.target.value })} /> - setForm({ ...form, code: event.target.value })} /> - setForm({ ...form, description: event.target.value })} /> - {Object.entries(groupedPermissions).map(([kind, items]) => ( -
-
{kind}
-
- {items.map((permission) => ( - - ))} -
-
- ))} -
- - -
- -
- - ); -} diff --git a/src/pages/ServicesPage.jsx b/src/pages/ServicesPage.jsx deleted file mode 100644 index 4d0f76f..0000000 --- a/src/pages/ServicesPage.jsx +++ /dev/null @@ -1,232 +0,0 @@ -import Add from "@mui/icons-material/Add"; -import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined"; -import PlayCircleOutlined from "@mui/icons-material/PlayCircleOutlined"; -import StopCircleOutlined from "@mui/icons-material/StopCircleOutlined"; -import WarningAmberOutlined from "@mui/icons-material/WarningAmberOutlined"; -import Drawer from "@mui/material/Drawer"; -import TextField from "@mui/material/TextField"; -import { useEffect, useMemo, useState } from "react"; -import { useOutletContext, useSearchParams } from "react-router-dom"; -import { - createService, - listServices, - restartService as restartServiceApi, - updateServiceStatus -} from "../api/services.js"; -import { useAuth } from "../auth/AuthProvider.jsx"; -import { Button } from "../components/base/Button.jsx"; -import { DataState } from "../components/base/DataState.jsx"; -import { FilterChip } from "../components/base/FilterChip.jsx"; -import { KpiCard } from "../components/base/KpiCard.jsx"; -import { PaginationBar } from "../components/base/PaginationBar.jsx"; -import { SearchBox } from "../components/base/SearchBox.jsx"; -import { useConfirm } from "../components/base/ConfirmProvider.jsx"; -import { useToast } from "../components/base/ToastProvider.jsx"; -import { PageHead } from "../components/layout/PageHead.jsx"; -import { DetailDrawer, InlineInspector } from "../components/service/DetailPanel.jsx"; -import { ServiceTable } from "../components/service/ServiceTable.jsx"; -import { detailTabs } from "../constants/status.js"; - -const filterItems = [ - ["all", "全部状态"], - ["running", "运行中"], - ["warning", "警告"], - ["error", "异常"], - ["stopped", "已停止"] -]; - -const emptyForm = { - name: "", - desc: "", - deploy: "prod-a", - version: "v1.0.0", - port: "8080", - replicas: "1/1", - response: "-", - cpu: 0, - memory: 0, - status: "running" -}; - -export function ServicesPage() { - const [query, setQuery] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [page, setPage] = useState(1); - const [data, setData] = useState({ items: [], total: 0, page: 1, pageSize: 10 }); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const [selectedId, setSelectedId] = useState(null); - const [drawerOpen, setDrawerOpen] = useState(false); - const [activeTab, setActiveTab] = useState(detailTabs[0]); - const [formOpen, setFormOpen] = useState(false); - const [form, setForm] = useState(emptyForm); - const { refreshTick } = useOutletContext(); - const [searchParams] = useSearchParams(); - const { can } = useAuth(); - const confirm = useConfirm(); - const { showToast } = useToast(); - - const load = async () => { - setLoading(true); - setError(""); - try { - const result = await listServices({ page, page_size: 10, keyword: query, status: statusFilter }); - setData(result); - if (!selectedId && result.items?.length) { - setSelectedId(Number(searchParams.get("service")) || result.items[0].id); - } - } catch (err) { - setError(err.message || "加载服务失败"); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - load(); - }, [page, query, statusFilter, refreshTick]); - - const stats = useMemo(() => { - const items = data.items || []; - return { - running: items.filter((service) => service.status === "running").length, - stopped: items.filter((service) => service.status === "stopped").length, - error: items.filter((service) => service.status === "error").length - }; - }, [data.items]); - - const selectedService = (data.items || []).find((service) => service.id === selectedId) || data.items?.[0]; - - const openDetail = (id) => { - setSelectedId(id); - setActiveTab(detailTabs[0]); - setDrawerOpen(true); - }; - - const setServiceStatus = async (id, status) => { - if (status === "stopped") { - const ok = await confirm({ - title: "停止服务", - message: "停止服务会写入操作日志,并让该服务进入不可用状态。", - confirmText: "停止", - tone: "danger" - }); - if (!ok) { - return; - } - } - await updateServiceStatus(id, status); - showToast("服务状态已更新", "success"); - load(); - }; - - const restartService = async (id) => { - const ok = await confirm({ title: "重启服务", message: "重启会刷新服务状态并记录操作日志。", confirmText: "重启" }); - if (!ok) { - return; - } - await restartServiceApi(id); - showToast("服务已重启", "success"); - load(); - }; - - const submitService = async (event) => { - event.preventDefault(); - try { - await createService({ ...form, cpu: Number(form.cpu), memory: Number(form.memory) }); - showToast("服务已创建", "success"); - setFormOpen(false); - setForm(emptyForm); - load(); - } catch (err) { - showToast(err.message || "创建服务失败", "error"); - } - }; - - return ( - <> - - {can("service:create") ? ( - - ) : null} - - -
- 来自后端资产} /> - 当前页统计} /> - 当前页统计} /> - 当前页统计} /> -
- -
- { setQuery(value); setPage(1); }} placeholder="搜索服务名称、IP、端口..." /> -
- {filterItems.map(([value, label]) => ( - { - setStatusFilter(value); - setPage(1); - }} - /> - ))} -
-
- - -
-
- - -
- -
-
- - setDrawerOpen(false)} - restartService={restartService} - service={selectedService} - setActiveTab={setActiveTab} - setServiceStatus={setServiceStatus} - /> - - setFormOpen(false)}> -
-

创建服务

- setForm({ ...form, name: event.target.value })} /> - setForm({ ...form, desc: event.target.value })} /> - setForm({ ...form, deploy: event.target.value })} /> - setForm({ ...form, version: event.target.value })} /> - setForm({ ...form, port: event.target.value })} /> - setForm({ ...form, cpu: event.target.value })} /> - setForm({ ...form, memory: event.target.value })} /> -
- - -
- -
- - ); -} diff --git a/src/pages/UserManagementPage.jsx b/src/pages/UserManagementPage.jsx deleted file mode 100644 index 5cea24b..0000000 --- a/src/pages/UserManagementPage.jsx +++ /dev/null @@ -1,335 +0,0 @@ -import Add from "@mui/icons-material/Add"; -import BlockOutlined from "@mui/icons-material/BlockOutlined"; -import DownloadOutlined from "@mui/icons-material/DownloadOutlined"; -import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined"; -import ShieldOutlined from "@mui/icons-material/ShieldOutlined"; -import VerifiedUserOutlined from "@mui/icons-material/VerifiedUserOutlined"; -import Drawer from "@mui/material/Drawer"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import Switch from "@mui/material/Switch"; -import TextField from "@mui/material/TextField"; -import { useEffect, useMemo, useState } from "react"; -import { useNavigate, useOutletContext } from "react-router-dom"; -import { listRoles } from "../api/roles.js"; -import { - batchUpdateUserStatus, - createUser, - exportUsers, - listUsers, - resetUserPassword, - updateUser, - updateUserStatus -} from "../api/users.js"; -import { useAuth } from "../auth/AuthProvider.jsx"; -import { Button } from "../components/base/Button.jsx"; -import { DataState } from "../components/base/DataState.jsx"; -import { FilterChip } from "../components/base/FilterChip.jsx"; -import { KpiCard } from "../components/base/KpiCard.jsx"; -import { PaginationBar } from "../components/base/PaginationBar.jsx"; -import { SearchBox } from "../components/base/SearchBox.jsx"; -import { useConfirm } from "../components/base/ConfirmProvider.jsx"; -import { useToast } from "../components/base/ToastProvider.jsx"; -import { PageHead } from "../components/layout/PageHead.jsx"; -import { UserTable } from "../components/user/UserTable.jsx"; -import { userStatusMeta } from "../data/users.js"; - -const statusFilters = [ - ["all", "全部状态"], - ["active", "启用"], - ["locked", "锁定"], - ["disabled", "停用"] -]; - -const emptyForm = { - username: "", - name: "", - team: "", - password: "", - status: "active", - mfaEnabled: false, - roleIds: [] -}; - -export function UserManagementPage() { - const [users, setUsers] = useState({ items: [], total: 0, page: 1, pageSize: 10 }); - const [roles, setRoles] = useState([]); - const [query, setQuery] = useState(""); - const [roleFilter, setRoleFilter] = useState("all"); - const [statusFilter, setStatusFilter] = useState("all"); - const [page, setPage] = useState(1); - const [selectedIds, setSelectedIds] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const [formOpen, setFormOpen] = useState(false); - const [editingUser, setEditingUser] = useState(null); - const [form, setForm] = useState(emptyForm); - const { refreshTick } = useOutletContext(); - const { can } = useAuth(); - const navigate = useNavigate(); - const confirm = useConfirm(); - const { showToast } = useToast(); - - const roleFilters = useMemo(() => [["all", "全部角色"], ...roles.map((role) => [role.name, role.name])], [roles]); - - const load = async () => { - setLoading(true); - setError(""); - try { - const [userData, roleData] = await Promise.all([ - listUsers({ page, page_size: 10, keyword: query, role: roleFilter, status: statusFilter }), - listRoles() - ]); - setUsers(userData); - setRoles(roleData || []); - setSelectedIds([]); - } catch (err) { - setError(err.message || "加载用户失败"); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - load(); - }, [page, query, roleFilter, statusFilter, refreshTick]); - - const stats = useMemo(() => { - const items = users.items || []; - const active = items.filter((user) => user.status === "active").length; - const locked = items.filter((user) => user.status === "locked").length; - const admin = items.filter((user) => String(user.role).includes("管理员")).length; - return { active, admin, locked }; - }, [users.items]); - - const openCreate = () => { - setEditingUser(null); - setForm(emptyForm); - setFormOpen(true); - }; - - const openEdit = (user) => { - setEditingUser(user); - setForm({ - username: user.account, - name: user.name, - team: user.team || "", - password: "", - status: user.status, - mfaEnabled: user.mfaEnabled, - roleIds: user.roleIds || [] - }); - setFormOpen(true); - }; - - const submitUser = async (event) => { - event.preventDefault(); - try { - if (editingUser) { - await updateUser(editingUser.id, { - name: form.name, - team: form.team, - status: form.status, - mfaEnabled: form.mfaEnabled, - roleIds: form.roleIds - }); - showToast("用户已更新", "success"); - } else { - const result = await createUser(form); - showToast(`用户已创建,初始密码:${result.initialPassword}`, "success"); - } - setFormOpen(false); - load(); - } catch (err) { - showToast(err.message || "保存用户失败", "error"); - } - }; - - const toggleLocked = async (user) => { - const nextStatus = user.status === "locked" ? "active" : "locked"; - const ok = await confirm({ - title: user.status === "locked" ? "解锁用户" : "锁定用户", - message: `${user.name} 的登录状态会立即变更。`, - confirmText: user.status === "locked" ? "解锁" : "锁定", - tone: user.status === "locked" ? "primary" : "danger" - }); - if (!ok) { - return; - } - await updateUserStatus(user.id, nextStatus); - showToast("用户状态已更新", "success"); - load(); - }; - - const toggleDisabled = async (user) => { - const nextStatus = user.status === "disabled" ? "active" : "disabled"; - const ok = await confirm({ - title: user.status === "disabled" ? "启用用户" : "停用用户", - message: `${user.name} 的账号状态会立即变更。`, - confirmText: user.status === "disabled" ? "启用" : "停用", - tone: user.status === "disabled" ? "primary" : "danger" - }); - if (!ok) { - return; - } - await updateUserStatus(user.id, nextStatus); - showToast("用户状态已更新", "success"); - load(); - }; - - const resetPassword = async (user) => { - const ok = await confirm({ - title: "重置密码", - message: `${user.name} 的密码会重置为系统生成的初始密码。`, - confirmText: "重置", - tone: "danger" - }); - if (!ok) { - return; - } - const result = await resetUserPassword(user.id); - showToast(`已重置,初始密码:${result.initialPassword}`, "success"); - }; - - const batchStatus = async (status) => { - if (!selectedIds.length) { - showToast("请先选择用户", "warning"); - return; - } - const ok = await confirm({ - title: "批量更新状态", - message: `将更新 ${selectedIds.length} 个用户状态。`, - confirmText: "确认", - tone: status === "active" ? "primary" : "danger" - }); - if (!ok) { - return; - } - await batchUpdateUserStatus(selectedIds, status); - showToast("批量状态已更新", "success"); - load(); - }; - - const downloadCSV = async () => { - const response = await exportUsers({ keyword: query, role: roleFilter, status: statusFilter }); - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = "hyapp-users.csv"; - link.click(); - window.URL.revokeObjectURL(url); - }; - - const setRoleChecked = (roleId, checked) => { - setForm((current) => ({ - ...current, - roleIds: checked ? [...current.roleIds, roleId] : current.roleIds.filter((id) => id !== roleId) - })); - }; - - return ( - <> - - {can("role:view") ? ( - - ) : null} - - {can("user:create") ? ( - - ) : null} - - -
- {stats.active} 个启用账户} /> - 当前页统计} /> - 可登录控制台} /> - 需管理员处理} /> -
- -
- { setQuery(value); setPage(1); }} placeholder="搜索用户、账号、角色、团队..." /> -
- {roleFilters.map(([value, label]) => ( - { setRoleFilter(value); setPage(1); }} /> - ))} -
-
- {statusFilters.map(([value, label]) => ( - { setStatusFilter(value); setPage(1); }} /> - ))} -
- {can("user:status") ? ( -
- - -
- ) : null} -
- - - - - - - setFormOpen(false)}> -
-

{editingUser ? "编辑用户" : "新增用户"}

- setForm({ ...form, username: event.target.value })} - /> - setForm({ ...form, name: event.target.value })} /> - setForm({ ...form, team: event.target.value })} /> - {!editingUser ? ( - setForm({ ...form, password: event.target.value })} /> - ) : null} -
角色
-
- {roles.map((role) => ( - - ))} -
- setForm({ ...form, mfaEnabled: event.target.checked })} />} - label={form.mfaEnabled ? "MFA 已开启" : "MFA 未开启"} - /> -
- - -
- -
- - ); -} diff --git a/src/shared/api/download.ts b/src/shared/api/download.ts new file mode 100644 index 0000000..fe7832f --- /dev/null +++ b/src/shared/api/download.ts @@ -0,0 +1,14 @@ +export async function downloadResponse(response: Response, filename: string) { + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + window.URL.revokeObjectURL(url); +} + +export async function downloadCsv(request: () => Promise, filename: string) { + const response = await request(); + await downloadResponse(response, filename); +} diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts new file mode 100644 index 0000000..2d9f083 --- /dev/null +++ b/src/shared/api/generated/endpoints.ts @@ -0,0 +1,899 @@ +/* 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 = { + appBanUser: "appBanUser", + appGetUser: "appGetUser", + appListUsers: "appListUsers", + appSetPassword: "appSetPassword", + appUnbanUser: "appUnbanUser", + appUpdateUser: "appUpdateUser", + batchUpdateUserStatus: "batchUpdateUserStatus", + cancelJob: "cancelJob", + changePassword: "changePassword", + closeAgency: "closeAgency", + createAgency: "createAgency", + createBD: "createBD", + createBDLeader: "createBDLeader", + createCoinSeller: "createCoinSeller", + createCountry: "createCountry", + createGift: "createGift", + createMenu: "createMenu", + createPermission: "createPermission", + createRegion: "createRegion", + createResource: "createResource", + createResourceGroup: "createResourceGroup", + createRole: "createRole", + createUser: "createUser", + createUserExportJob: "createUserExportJob", + dashboardOverview: "dashboardOverview", + deleteCountry: "deleteCountry", + deleteMenu: "deleteMenu", + deleteNotification: "deleteNotification", + deletePermission: "deletePermission", + deleteRole: "deleteRole", + deleteRoom: "deleteRoom", + disableCountry: "disableCountry", + disableGift: "disableGift", + disableRegion: "disableRegion", + disableResource: "disableResource", + disableResourceGroup: "disableResourceGroup", + downloadJobArtifact: "downloadJobArtifact", + enableCountry: "enableCountry", + enableGift: "enableGift", + enableRegion: "enableRegion", + enableResource: "enableResource", + enableResourceGroup: "enableResourceGroup", + exportLoginLogs: "exportLoginLogs", + exportOperationLogs: "exportOperationLogs", + exportUsers: "exportUsers", + getCountry: "getCountry", + getRegion: "getRegion", + getResource: "getResource", + getResourceGroup: "getResourceGroup", + getRoleDataScopes: "getRoleDataScopes", + getUser: "getUser", + grantResource: "grantResource", + grantResourceGroup: "grantResourceGroup", + listAgencies: "listAgencies", + listApps: "listApps", + listBDLeaders: "listBDLeaders", + listBDs: "listBDs", + listCoinSellers: "listCoinSellers", + listCountries: "listCountries", + listGifts: "listGifts", + listH5Links: "listH5Links", + listHosts: "listHosts", + listJobs: "listJobs", + listLoginLogs: "listLoginLogs", + listNotifications: "listNotifications", + listOperationLogs: "listOperationLogs", + listPermissions: "listPermissions", + listRegions: "listRegions", + listResourceGrants: "listResourceGrants", + listResourceGroups: "listResourceGroups", + listResources: "listResources", + listRoles: "listRoles", + listRooms: "listRooms", + listSystemMenus: "listSystemMenus", + listUsers: "listUsers", + login: "login", + logout: "logout", + markAllNotificationsRead: "markAllNotificationsRead", + markNotificationRead: "markNotificationRead", + me: "me", + navigationMenus: "navigationMenus", + refresh: "refresh", + replaceRegionCountries: "replaceRegionCountries", + replaceRoleDataScopes: "replaceRoleDataScopes", + replaceRolePermissions: "replaceRolePermissions", + resetUserPassword: "resetUserPassword", + search: "search", + setAgencyJoinEnabled: "setAgencyJoinEnabled", + setBDLeaderStatus: "setBDLeaderStatus", + setBDStatus: "setBDStatus", + setCoinSellerStatus: "setCoinSellerStatus", + sortMenus: "sortMenus", + syncPermissions: "syncPermissions", + updateCountry: "updateCountry", + updateGift: "updateGift", + updateH5Links: "updateH5Links", + updateMenu: "updateMenu", + updateMenuVisible: "updateMenuVisible", + updatePermission: "updatePermission", + updateRegion: "updateRegion", + updateResource: "updateResource", + updateResourceGroup: "updateResourceGroup", + updateResourceGroupItems: "updateResourceGroupItems", + updateRole: "updateRole", + updateRoom: "updateRoom", + updateUser: "updateUser", + updateUserStatus: "updateUserStatus", + uploadFile: "uploadFile", + uploadImage: "uploadImage" +} as const; + +export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS]; + +export const API_ENDPOINTS: Record = { + appBanUser: { + method: "POST", + operationId: API_OPERATIONS.appBanUser, + path: "/v1/app/users/{id}/ban", + permission: "app-user:status", + permissions: ["app-user:status"] + }, + appGetUser: { + method: "GET", + operationId: API_OPERATIONS.appGetUser, + path: "/v1/app/users/{id}", + permission: "app-user:view", + permissions: ["app-user:view"] + }, + appListUsers: { + method: "GET", + operationId: API_OPERATIONS.appListUsers, + path: "/v1/app/users", + permission: "app-user:view", + permissions: ["app-user:view"] + }, + appSetPassword: { + method: "POST", + operationId: API_OPERATIONS.appSetPassword, + path: "/v1/app/users/{id}/password", + permission: "app-user:password", + permissions: ["app-user:password"] + }, + appUnbanUser: { + method: "POST", + operationId: API_OPERATIONS.appUnbanUser, + path: "/v1/app/users/{id}/unban", + permission: "app-user:status", + permissions: ["app-user:status"] + }, + appUpdateUser: { + method: "PATCH", + operationId: API_OPERATIONS.appUpdateUser, + path: "/v1/app/users/{id}", + permission: "app-user:update", + permissions: ["app-user:update"] + }, + batchUpdateUserStatus: { + method: "POST", + operationId: API_OPERATIONS.batchUpdateUserStatus, + path: "/v1/users/batch/status", + permission: "user:status", + permissions: ["user:status"] + }, + cancelJob: { + method: "POST", + operationId: API_OPERATIONS.cancelJob, + path: "/v1/jobs/{id}/cancel", + permission: "job:cancel", + permissions: ["job:cancel"] + }, + changePassword: { + method: "POST", + operationId: API_OPERATIONS.changePassword, + path: "/v1/auth/change-password" + }, + closeAgency: { + method: "POST", + operationId: API_OPERATIONS.closeAgency, + path: "/v1/admin/agencies/{agency_id}/close", + permission: "agency:status", + permissions: ["agency:status"] + }, + createAgency: { + method: "POST", + operationId: API_OPERATIONS.createAgency, + path: "/v1/admin/agencies", + permission: "agency:create", + permissions: ["agency:create"] + }, + createBD: { + method: "POST", + operationId: API_OPERATIONS.createBD, + path: "/v1/admin/bds", + permission: "bd:create", + permissions: ["bd:create"] + }, + createBDLeader: { + method: "POST", + operationId: API_OPERATIONS.createBDLeader, + path: "/v1/admin/bd-leaders", + permission: "bd:create", + permissions: ["bd:create"] + }, + createCoinSeller: { + method: "POST", + operationId: API_OPERATIONS.createCoinSeller, + path: "/v1/admin/coin-sellers", + permission: "coin-seller:create", + permissions: ["coin-seller:create"] + }, + createCountry: { + method: "POST", + operationId: API_OPERATIONS.createCountry, + path: "/v1/admin/countries", + permission: "country:create", + permissions: ["country:create"] + }, + createGift: { + method: "POST", + operationId: API_OPERATIONS.createGift, + path: "/v1/admin/gifts", + permission: "gift:create", + permissions: ["gift:create"] + }, + createMenu: { + method: "POST", + operationId: API_OPERATIONS.createMenu, + path: "/v1/system/menus", + permission: "menu:create", + permissions: ["menu:create"] + }, + createPermission: { + method: "POST", + operationId: API_OPERATIONS.createPermission, + path: "/v1/permissions", + permission: "permission:create", + permissions: ["permission:create"] + }, + createRegion: { + method: "POST", + operationId: API_OPERATIONS.createRegion, + path: "/v1/admin/regions", + permission: "region:create", + permissions: ["region:create"] + }, + createResource: { + method: "POST", + operationId: API_OPERATIONS.createResource, + path: "/v1/admin/resources", + permission: "resource:create", + permissions: ["resource:create"] + }, + createResourceGroup: { + method: "POST", + operationId: API_OPERATIONS.createResourceGroup, + path: "/v1/admin/resource-groups", + permission: "resource-group:create", + permissions: ["resource-group:create"] + }, + createRole: { + method: "POST", + operationId: API_OPERATIONS.createRole, + path: "/v1/roles", + permission: "role:create", + permissions: ["role:create","role:manage"] + }, + createUser: { + method: "POST", + operationId: API_OPERATIONS.createUser, + path: "/v1/users", + permission: "user:create", + permissions: ["user:create"] + }, + createUserExportJob: { + method: "POST", + operationId: API_OPERATIONS.createUserExportJob, + path: "/v1/exports/users", + permission: "export:create", + permissions: ["export:create"] + }, + dashboardOverview: { + method: "GET", + operationId: API_OPERATIONS.dashboardOverview, + path: "/v1/dashboard/overview", + permission: "overview:view", + permissions: ["overview:view"] + }, + deleteCountry: { + method: "DELETE", + operationId: API_OPERATIONS.deleteCountry, + path: "/v1/admin/countries/{country_id}", + permission: "country:status", + permissions: ["country:status"] + }, + deleteMenu: { + method: "DELETE", + operationId: API_OPERATIONS.deleteMenu, + path: "/v1/system/menus/{id}", + permission: "menu:delete", + permissions: ["menu:delete"] + }, + deleteNotification: { + method: "DELETE", + operationId: API_OPERATIONS.deleteNotification, + path: "/v1/notifications/{id}", + permission: "notification:delete", + permissions: ["notification:delete"] + }, + deletePermission: { + method: "DELETE", + operationId: API_OPERATIONS.deletePermission, + path: "/v1/permissions/{id}", + permission: "permission:delete", + permissions: ["permission:delete"] + }, + deleteRole: { + method: "DELETE", + operationId: API_OPERATIONS.deleteRole, + path: "/v1/roles/{id}", + permission: "role:delete", + permissions: ["role:delete","role:manage"] + }, + deleteRoom: { + method: "DELETE", + operationId: API_OPERATIONS.deleteRoom, + path: "/v1/admin/rooms/{room_id}", + permission: "room:delete", + permissions: ["room:delete"] + }, + disableCountry: { + method: "POST", + operationId: API_OPERATIONS.disableCountry, + path: "/v1/admin/countries/{country_id}/disable", + permission: "country:status", + permissions: ["country:status"] + }, + disableGift: { + method: "POST", + operationId: API_OPERATIONS.disableGift, + path: "/v1/admin/gifts/{gift_id}/disable", + permission: "gift:status", + permissions: ["gift:status"] + }, + disableRegion: { + method: "DELETE", + operationId: API_OPERATIONS.disableRegion, + path: "/v1/admin/regions/{region_id}", + permission: "region:status", + permissions: ["region:status"] + }, + disableResource: { + method: "POST", + operationId: API_OPERATIONS.disableResource, + path: "/v1/admin/resources/{resource_id}/disable", + permission: "resource:update", + permissions: ["resource:update"] + }, + disableResourceGroup: { + method: "POST", + operationId: API_OPERATIONS.disableResourceGroup, + path: "/v1/admin/resource-groups/{group_id}/disable", + permission: "resource-group:update", + permissions: ["resource-group:update"] + }, + downloadJobArtifact: { + method: "GET", + operationId: API_OPERATIONS.downloadJobArtifact, + path: "/v1/jobs/{id}/artifact", + permission: "job:view", + permissions: ["job:view"] + }, + enableCountry: { + method: "POST", + operationId: API_OPERATIONS.enableCountry, + path: "/v1/admin/countries/{country_id}/enable", + permission: "country:status", + permissions: ["country:status"] + }, + enableGift: { + method: "POST", + operationId: API_OPERATIONS.enableGift, + path: "/v1/admin/gifts/{gift_id}/enable", + permission: "gift:status", + permissions: ["gift:status"] + }, + enableRegion: { + method: "POST", + operationId: API_OPERATIONS.enableRegion, + path: "/v1/admin/regions/{region_id}/enable", + permission: "region:status", + permissions: ["region:status"] + }, + enableResource: { + method: "POST", + operationId: API_OPERATIONS.enableResource, + path: "/v1/admin/resources/{resource_id}/enable", + permission: "resource:update", + permissions: ["resource:update"] + }, + enableResourceGroup: { + method: "POST", + operationId: API_OPERATIONS.enableResourceGroup, + path: "/v1/admin/resource-groups/{group_id}/enable", + permission: "resource-group:update", + permissions: ["resource-group:update"] + }, + exportLoginLogs: { + method: "GET", + operationId: API_OPERATIONS.exportLoginLogs, + path: "/v1/logs/login/export", + permission: "log:export", + permissions: ["log:export"] + }, + exportOperationLogs: { + method: "GET", + operationId: API_OPERATIONS.exportOperationLogs, + path: "/v1/logs/operations/export", + permission: "log:export", + permissions: ["log:export"] + }, + exportUsers: { + method: "GET", + operationId: API_OPERATIONS.exportUsers, + path: "/v1/users/export", + permission: "user:export", + permissions: ["user:export"] + }, + getCountry: { + method: "GET", + operationId: API_OPERATIONS.getCountry, + path: "/v1/admin/countries/{country_id}", + permission: "country:view", + permissions: ["country:view"] + }, + getRegion: { + method: "GET", + operationId: API_OPERATIONS.getRegion, + path: "/v1/admin/regions/{region_id}", + permission: "region:view", + permissions: ["region:view"] + }, + getResource: { + method: "GET", + operationId: API_OPERATIONS.getResource, + path: "/v1/admin/resources/{resource_id}", + permission: "resource:view", + permissions: ["resource:view"] + }, + getResourceGroup: { + method: "GET", + operationId: API_OPERATIONS.getResourceGroup, + path: "/v1/admin/resource-groups/{group_id}", + permission: "resource-group:view", + permissions: ["resource-group:view"] + }, + getRoleDataScopes: { + method: "GET", + operationId: API_OPERATIONS.getRoleDataScopes, + path: "/v1/roles/{id}/data-scopes", + permission: "role:data-scope", + permissions: ["role:data-scope"] + }, + getUser: { + method: "GET", + operationId: API_OPERATIONS.getUser, + path: "/v1/users/{id}", + permission: "user:view", + permissions: ["user:view"] + }, + grantResource: { + method: "POST", + operationId: API_OPERATIONS.grantResource, + path: "/v1/admin/resource-grants/resource", + permission: "resource-grant:create", + permissions: ["resource-grant:create"] + }, + grantResourceGroup: { + method: "POST", + operationId: API_OPERATIONS.grantResourceGroup, + path: "/v1/admin/resource-grants/group", + permission: "resource-grant:create", + permissions: ["resource-grant:create"] + }, + listAgencies: { + method: "GET", + operationId: API_OPERATIONS.listAgencies, + path: "/v1/admin/agencies", + permission: "agency:view", + permissions: ["agency:view"] + }, + listApps: { + method: "GET", + operationId: API_OPERATIONS.listApps, + path: "/v1/admin/apps" + }, + listBDLeaders: { + method: "GET", + operationId: API_OPERATIONS.listBDLeaders, + path: "/v1/admin/bd-leaders", + permission: "bd:view", + permissions: ["bd:view"] + }, + listBDs: { + method: "GET", + operationId: API_OPERATIONS.listBDs, + path: "/v1/admin/bds", + permission: "bd:view", + permissions: ["bd:view"] + }, + listCoinSellers: { + method: "GET", + operationId: API_OPERATIONS.listCoinSellers, + path: "/v1/admin/coin-sellers", + permission: "coin-seller:view", + permissions: ["coin-seller:view"] + }, + listCountries: { + method: "GET", + operationId: API_OPERATIONS.listCountries, + path: "/v1/admin/countries", + permission: "country:view", + permissions: ["country:view"] + }, + listGifts: { + method: "GET", + operationId: API_OPERATIONS.listGifts, + path: "/v1/admin/gifts", + permission: "gift:view", + permissions: ["gift:view"] + }, + listH5Links: { + method: "GET", + operationId: API_OPERATIONS.listH5Links, + path: "/v1/admin/app-config/h5-links", + permission: "app-config:view", + permissions: ["app-config:view"] + }, + listHosts: { + method: "GET", + operationId: API_OPERATIONS.listHosts, + path: "/v1/admin/hosts", + permission: "host:view", + permissions: ["host:view"] + }, + listJobs: { + method: "GET", + operationId: API_OPERATIONS.listJobs, + path: "/v1/jobs", + permission: "job:view", + permissions: ["job:view"] + }, + listLoginLogs: { + method: "GET", + operationId: API_OPERATIONS.listLoginLogs, + path: "/v1/logs/login", + permission: "log:view", + permissions: ["log:view"] + }, + listNotifications: { + method: "GET", + operationId: API_OPERATIONS.listNotifications, + path: "/v1/notifications", + permission: "notification:view", + permissions: ["notification:view"] + }, + listOperationLogs: { + method: "GET", + operationId: API_OPERATIONS.listOperationLogs, + path: "/v1/logs/operations", + permission: "log:view", + permissions: ["log:view"] + }, + listPermissions: { + method: "GET", + operationId: API_OPERATIONS.listPermissions, + path: "/v1/permissions", + permission: "permission:view", + permissions: ["permission:view","role:permission","role:manage"] + }, + listRegions: { + method: "GET", + operationId: API_OPERATIONS.listRegions, + path: "/v1/admin/regions", + permission: "region:view", + permissions: ["region:view"] + }, + listResourceGrants: { + method: "GET", + operationId: API_OPERATIONS.listResourceGrants, + path: "/v1/admin/resource-grants", + permission: "resource-grant:view", + permissions: ["resource-grant:view"] + }, + listResourceGroups: { + method: "GET", + operationId: API_OPERATIONS.listResourceGroups, + path: "/v1/admin/resource-groups", + permission: "resource-group:view", + permissions: ["resource-group:view"] + }, + listResources: { + method: "GET", + operationId: API_OPERATIONS.listResources, + path: "/v1/admin/resources", + permission: "resource:view", + permissions: ["resource:view"] + }, + listRoles: { + method: "GET", + operationId: API_OPERATIONS.listRoles, + path: "/v1/roles", + permission: "role:view", + permissions: ["role:view"] + }, + listRooms: { + method: "GET", + operationId: API_OPERATIONS.listRooms, + path: "/v1/admin/rooms", + permission: "room:view", + permissions: ["room:view"] + }, + listSystemMenus: { + method: "GET", + operationId: API_OPERATIONS.listSystemMenus, + path: "/v1/system/menus", + permission: "menu:view", + permissions: ["menu:view"] + }, + listUsers: { + method: "GET", + operationId: API_OPERATIONS.listUsers, + path: "/v1/users", + permission: "user:view", + permissions: ["user:view"] + }, + login: { + method: "POST", + operationId: API_OPERATIONS.login, + path: "/v1/auth/login" + }, + logout: { + method: "POST", + operationId: API_OPERATIONS.logout, + path: "/v1/auth/logout" + }, + markAllNotificationsRead: { + method: "PATCH", + operationId: API_OPERATIONS.markAllNotificationsRead, + path: "/v1/notifications/read-all", + permission: "notification:read-all", + permissions: ["notification:read-all"] + }, + markNotificationRead: { + method: "PATCH", + operationId: API_OPERATIONS.markNotificationRead, + path: "/v1/notifications/{id}/read", + permission: "notification:read", + permissions: ["notification:read"] + }, + me: { + method: "GET", + operationId: API_OPERATIONS.me, + path: "/v1/auth/me" + }, + navigationMenus: { + method: "GET", + operationId: API_OPERATIONS.navigationMenus, + path: "/v1/navigation/menus" + }, + refresh: { + method: "POST", + operationId: API_OPERATIONS.refresh, + path: "/v1/auth/refresh" + }, + replaceRegionCountries: { + method: "PUT", + operationId: API_OPERATIONS.replaceRegionCountries, + path: "/v1/admin/regions/{region_id}/countries", + permission: "region:update", + permissions: ["region:update"] + }, + replaceRoleDataScopes: { + method: "PUT", + operationId: API_OPERATIONS.replaceRoleDataScopes, + path: "/v1/roles/{id}/data-scopes", + permission: "role:data-scope", + permissions: ["role:data-scope"] + }, + replaceRolePermissions: { + method: "PUT", + operationId: API_OPERATIONS.replaceRolePermissions, + path: "/v1/roles/{id}/permissions", + permission: "role:permission", + permissions: ["role:permission","role:manage"] + }, + resetUserPassword: { + method: "POST", + operationId: API_OPERATIONS.resetUserPassword, + path: "/v1/users/{id}/reset-password", + permission: "user:reset-password", + permissions: ["user:reset-password"] + }, + search: { + method: "GET", + operationId: API_OPERATIONS.search, + path: "/v1/search" + }, + setAgencyJoinEnabled: { + method: "POST", + operationId: API_OPERATIONS.setAgencyJoinEnabled, + path: "/v1/admin/agencies/{agency_id}/join-enabled", + permission: "agency:status", + permissions: ["agency:status"] + }, + setBDLeaderStatus: { + method: "PATCH", + operationId: API_OPERATIONS.setBDLeaderStatus, + path: "/v1/admin/bd-leaders/{user_id}/status", + permission: "bd:update", + permissions: ["bd:update"] + }, + setBDStatus: { + method: "PATCH", + operationId: API_OPERATIONS.setBDStatus, + path: "/v1/admin/bds/{user_id}/status", + permission: "bd:update", + permissions: ["bd:update"] + }, + setCoinSellerStatus: { + method: "PATCH", + operationId: API_OPERATIONS.setCoinSellerStatus, + path: "/v1/admin/coin-sellers/{user_id}/status", + permission: "coin-seller:update", + permissions: ["coin-seller:update"] + }, + sortMenus: { + method: "PUT", + operationId: API_OPERATIONS.sortMenus, + path: "/v1/system/menus/sort", + permission: "menu:sort", + permissions: ["menu:sort"] + }, + syncPermissions: { + method: "POST", + operationId: API_OPERATIONS.syncPermissions, + path: "/v1/permissions/sync", + permission: "permission:sync", + permissions: ["permission:sync"] + }, + updateCountry: { + method: "PATCH", + operationId: API_OPERATIONS.updateCountry, + path: "/v1/admin/countries/{country_id}", + permission: "country:update", + permissions: ["country:update"] + }, + updateGift: { + method: "PUT", + operationId: API_OPERATIONS.updateGift, + path: "/v1/admin/gifts/{gift_id}", + permission: "gift:update", + permissions: ["gift:update"] + }, + updateH5Links: { + method: "PUT", + operationId: API_OPERATIONS.updateH5Links, + path: "/v1/admin/app-config/h5-links", + permission: "app-config:update", + permissions: ["app-config:update"] + }, + updateMenu: { + method: "PATCH", + operationId: API_OPERATIONS.updateMenu, + path: "/v1/system/menus/{id}", + permission: "menu:update", + permissions: ["menu:update"] + }, + updateMenuVisible: { + method: "PATCH", + operationId: API_OPERATIONS.updateMenuVisible, + path: "/v1/system/menus/{id}/visible", + permission: "menu:visible", + permissions: ["menu:visible"] + }, + updatePermission: { + method: "PATCH", + operationId: API_OPERATIONS.updatePermission, + path: "/v1/permissions/{id}", + permission: "permission:update", + permissions: ["permission:update"] + }, + updateRegion: { + method: "PATCH", + operationId: API_OPERATIONS.updateRegion, + path: "/v1/admin/regions/{region_id}", + permission: "region:update", + permissions: ["region:update"] + }, + updateResource: { + method: "PUT", + operationId: API_OPERATIONS.updateResource, + path: "/v1/admin/resources/{resource_id}", + permission: "resource:update", + permissions: ["resource:update"] + }, + updateResourceGroup: { + method: "PUT", + operationId: API_OPERATIONS.updateResourceGroup, + path: "/v1/admin/resource-groups/{group_id}", + permission: "resource-group:update", + permissions: ["resource-group:update"] + }, + updateResourceGroupItems: { + method: "PUT", + operationId: API_OPERATIONS.updateResourceGroupItems, + path: "/v1/admin/resource-groups/{group_id}/items", + permission: "resource-group:update", + permissions: ["resource-group:update"] + }, + updateRole: { + method: "PATCH", + operationId: API_OPERATIONS.updateRole, + path: "/v1/roles/{id}", + permission: "role:update", + permissions: ["role:update","role:manage"] + }, + updateRoom: { + method: "PATCH", + operationId: API_OPERATIONS.updateRoom, + path: "/v1/admin/rooms/{room_id}", + permission: "room:update", + permissions: ["room:update"] + }, + updateUser: { + method: "PATCH", + operationId: API_OPERATIONS.updateUser, + path: "/v1/users/{id}", + permission: "user:update", + permissions: ["user:update"] + }, + updateUserStatus: { + method: "PATCH", + operationId: API_OPERATIONS.updateUserStatus, + path: "/v1/users/{id}/status", + permission: "user:status", + permissions: ["user:status"] + }, + uploadFile: { + method: "POST", + operationId: API_OPERATIONS.uploadFile, + path: "/v1/admin/files/upload", + permission: "upload:create", + permissions: ["upload:create"] + }, + uploadImage: { + method: "POST", + operationId: API_OPERATIONS.uploadImage, + path: "/v1/admin/files/image/upload", + permission: "upload:create", + permissions: ["upload:create"] + } +}; + +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; + +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; +} diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts new file mode 100644 index 0000000..d33546d --- /dev/null +++ b/src/shared/api/generated/schema.d.ts @@ -0,0 +1,3257 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/admin/agencies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listAgencies"]; + put?: never; + post: operations["createAgency"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/agencies/{agency_id}/close": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["closeAgency"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/agencies/{agency_id}/join-enabled": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["setAgencyJoinEnabled"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/app-config/h5-links": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listH5Links"]; + put: operations["updateH5Links"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/apps": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listApps"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/bd-leaders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listBDLeaders"]; + put?: never; + post: operations["createBDLeader"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/bd-leaders/{user_id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["setBDLeaderStatus"]; + trace?: never; + }; + "/admin/bds": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listBDs"]; + put?: never; + post: operations["createBD"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/bds/{user_id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["setBDStatus"]; + trace?: never; + }; + "/admin/coin-sellers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listCoinSellers"]; + put?: never; + post: operations["createCoinSeller"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/coin-sellers/{user_id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["setCoinSellerStatus"]; + trace?: never; + }; + "/admin/countries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listCountries"]; + put?: never; + post: operations["createCountry"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/countries/{country_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getCountry"]; + put?: never; + post?: never; + delete: operations["deleteCountry"]; + options?: never; + head?: never; + patch: operations["updateCountry"]; + trace?: never; + }; + "/admin/countries/{country_id}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["disableCountry"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/countries/{country_id}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["enableCountry"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/files/image/upload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["uploadImage"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/files/upload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["uploadFile"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/gifts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listGifts"]; + put?: never; + post: operations["createGift"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/gifts/{gift_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateGift"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/gifts/{gift_id}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["disableGift"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/gifts/{gift_id}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["enableGift"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/hosts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listHosts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/regions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listRegions"]; + put?: never; + post: operations["createRegion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/regions/{region_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getRegion"]; + put?: never; + post?: never; + delete: operations["disableRegion"]; + options?: never; + head?: never; + patch: operations["updateRegion"]; + trace?: never; + }; + "/admin/regions/{region_id}/countries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["replaceRegionCountries"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/regions/{region_id}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["enableRegion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/resource-grants": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listResourceGrants"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/resource-grants/group": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["grantResourceGroup"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/resource-grants/resource": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["grantResource"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/resource-groups": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listResourceGroups"]; + put?: never; + post: operations["createResourceGroup"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/resource-groups/{group_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getResourceGroup"]; + put: operations["updateResourceGroup"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/resource-groups/{group_id}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["disableResourceGroup"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/resource-groups/{group_id}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["enableResourceGroup"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/resource-groups/{group_id}/items": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateResourceGroupItems"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/resources": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listResources"]; + put?: never; + post: operations["createResource"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/resources/{resource_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getResource"]; + put: operations["updateResource"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/resources/{resource_id}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["disableResource"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/resources/{resource_id}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["enableResource"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/rooms": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listRooms"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/rooms/{room_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteRoom"]; + options?: never; + head?: never; + patch: operations["updateRoom"]; + trace?: never; + }; + "/app/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["appListUsers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/app/users/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["appGetUser"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["appUpdateUser"]; + trace?: never; + }; + "/app/users/{id}/ban": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["appBanUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/app/users/{id}/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["appSetPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/app/users/{id}/unban": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["appUnbanUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/change-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["changePassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["logout"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["me"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["refresh"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/dashboard/overview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["dashboardOverview"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/exports/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["createUserExportJob"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/jobs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listJobs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/jobs/{id}/artifact": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadJobArtifact"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/jobs/{id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["cancelJob"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/logs/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listLoginLogs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/logs/login/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["exportLoginLogs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/logs/operations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listOperationLogs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/logs/operations/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["exportOperationLogs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/navigation/menus": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["navigationMenus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/notifications": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listNotifications"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/notifications/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteNotification"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/notifications/{id}/read": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["markNotificationRead"]; + trace?: never; + }; + "/notifications/read-all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["markAllNotificationsRead"]; + trace?: never; + }; + "/permissions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPermissions"]; + put?: never; + post: operations["createPermission"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/permissions/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deletePermission"]; + options?: never; + head?: never; + patch: operations["updatePermission"]; + trace?: never; + }; + "/permissions/sync": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["syncPermissions"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/roles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listRoles"]; + put?: never; + post: operations["createRole"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/roles/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteRole"]; + options?: never; + head?: never; + patch: operations["updateRole"]; + trace?: never; + }; + "/roles/{id}/data-scopes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getRoleDataScopes"]; + put: operations["replaceRoleDataScopes"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/roles/{id}/permissions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["replaceRolePermissions"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["search"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/system/menus": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listSystemMenus"]; + put?: never; + post: operations["createMenu"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/system/menus/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteMenu"]; + options?: never; + head?: never; + patch: operations["updateMenu"]; + trace?: never; + }; + "/system/menus/{id}/visible": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["updateMenuVisible"]; + trace?: never; + }; + "/system/menus/sort": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["sortMenus"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listUsers"]; + put?: never; + post: operations["createUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getUser"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["updateUser"]; + trace?: never; + }; + "/users/{id}/reset-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["resetUserPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/{id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["updateUserStatus"]; + trace?: never; + }; + "/users/batch/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["batchUpdateUserStatus"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["exportUsers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + ApiPageAgency: { + items: components["schemas"]["Agency"][]; + page: number; + pageSize: number; + total: number; + }; + ApiPageBDProfile: { + items: components["schemas"]["BDProfile"][]; + page: number; + pageSize: number; + total: number; + }; + ApiPageHostProfile: { + items: components["schemas"]["HostProfile"][]; + page: number; + pageSize: number; + total: number; + }; + ApiPageLog: { + items: components["schemas"]["Log"][]; + page: number; + pageSize: number; + total: number; + }; + ApiPageUser: { + items: components["schemas"]["AdminUser"][]; + page: number; + pageSize: number; + total: number; + }; + ApiResponseCreateUserResult: components["schemas"]["Envelope"] & { + data?: components["schemas"]["CreateUserResult"]; + }; + ApiResponseAgencyPage: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ApiPageAgency"]; + }; + ApiResponseBDProfilePage: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ApiPageBDProfile"]; + }; + ApiResponseDashboardOverview: components["schemas"]["Envelope"] & { + data?: components["schemas"]["DashboardOverview"]; + }; + ApiResponseEmpty: components["schemas"]["Envelope"]; + ApiResponseHostProfilePage: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ApiPageHostProfile"]; + }; + ApiResponseLogPage: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ApiPageLog"]; + }; + ApiResponseMenu: components["schemas"]["Envelope"] & { + data?: components["schemas"]["Menu"]; + }; + ApiResponseMenuList: components["schemas"]["Envelope"] & { + data?: components["schemas"]["Menu"][]; + }; + ApiResponseNotification: components["schemas"]["Envelope"] & { + data?: components["schemas"]["Notification"]; + }; + ApiResponseNotificationList: components["schemas"]["Envelope"] & { + data?: components["schemas"]["NotificationList"]; + }; + ApiResponsePermission: components["schemas"]["Envelope"] & { + data?: components["schemas"]["Permission"]; + }; + ApiResponsePermissionList: components["schemas"]["Envelope"] & { + data?: components["schemas"]["Permission"][]; + }; + ApiResponseResetPasswordResult: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ResetPasswordResult"]; + }; + ApiResponseRole: components["schemas"]["Envelope"] & { + data?: components["schemas"]["Role"]; + }; + ApiResponseRoleList: components["schemas"]["Envelope"] & { + data?: components["schemas"]["Role"][]; + }; + ApiResponseSearchList: components["schemas"]["Envelope"] & { + data?: components["schemas"]["SearchResult"][]; + }; + ApiResponseSession: components["schemas"]["Envelope"] & { + data?: components["schemas"]["Session"]; + }; + ApiResponseUser: components["schemas"]["Envelope"] & { + data?: components["schemas"]["AdminUser"]; + }; + ApiResponseUserPage: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ApiPageUser"]; + }; + AdminUser: { + account: string; + id: number; + lastLogin?: string; + mfa?: string; + mfaEnabled?: boolean; + name: string; + role?: string; + roleIds?: number[]; + status: string; + team?: string; + }; + Agency: { + agencyId: number; + createdAtMs?: number; + createdByUserId?: number; + joinEnabled?: boolean; + maxHosts?: number; + name?: string; + ownerUserId?: number; + parentBdUserId?: number; + regionId?: number; + status?: string; + updatedAtMs?: number; + }; + BDProfile: { + createdAtMs?: number; + createdByUserId?: number; + parentLeaderUserId?: number; + regionId?: number; + role?: string; + status?: string; + updatedAtMs?: number; + userId: number; + }; + CreateUserResult: { + initialPassword?: string; + }; + DashboardOverview: { + [key: string]: unknown; + }; + Envelope: { + code?: number; + data?: unknown; + message?: string; + }; + HostProfile: { + createdAtMs?: number; + currentAgencyId?: number; + currentMembershipId?: number; + firstBecameHostAtMs?: number; + regionId?: number; + source?: string; + status?: string; + updatedAtMs?: number; + userId: number; + }; + Log: { + [key: string]: unknown; + }; + Menu: { + [key: string]: unknown; + }; + MenuInput: { + [key: string]: unknown; + }; + Notification: { + [key: string]: unknown; + }; + NotificationList: { + items: components["schemas"]["Notification"][]; + unread: number; + }; + Permission: { + [key: string]: unknown; + }; + PermissionInput: { + [key: string]: unknown; + }; + ResetPasswordResult: { + initialPassword?: string; + }; + Role: { + [key: string]: unknown; + }; + RoleInput: { + [key: string]: unknown; + }; + SearchResult: { + [key: string]: unknown; + }; + Session: { + [key: string]: unknown; + }; + UserInput: { + [key: string]: unknown; + }; + }; + responses: { + /** @description OK */ + AgencyPageResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseAgencyPage"]; + }; + }; + /** @description OK */ + BDProfilePageResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseBDProfilePage"]; + }; + }; + /** @description OK */ + CreateUserResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseCreateUserResult"]; + }; + }; + /** @description OK */ + DashboardOverviewResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseDashboardOverview"]; + }; + }; + /** @description OK */ + EmptyResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseEmpty"]; + }; + }; + /** @description OK */ + HostProfilePageResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseHostProfilePage"]; + }; + }; + /** @description OK */ + LogPageResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseLogPage"]; + }; + }; + /** @description OK */ + MenuListResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseMenuList"]; + }; + }; + /** @description OK */ + MenuResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseMenu"]; + }; + }; + /** @description OK */ + NotificationListResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseNotificationList"]; + }; + }; + /** @description OK */ + NotificationResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseNotification"]; + }; + }; + /** @description OK */ + PermissionListResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponsePermissionList"]; + }; + }; + /** @description OK */ + PermissionResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponsePermission"]; + }; + }; + /** @description OK */ + ResetPasswordResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseResetPasswordResult"]; + }; + }; + /** @description OK */ + RoleListResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseRoleList"]; + }; + }; + /** @description OK */ + RoleResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseRole"]; + }; + }; + /** @description OK */ + SearchResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseSearchList"]; + }; + }; + /** @description OK */ + SessionResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseSession"]; + }; + }; + /** @description OK */ + UserPageResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseUserPage"]; + }; + }; + /** @description OK */ + UserResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseUser"]; + }; + }; + }; + parameters: { + AgencyId: number; + Id: number; + Keyword: string; + Page: number; + PageSize: number; + ParentBdUserId: number; + ParentLeaderUserId: number; + RegionId: number; + Status: string; + }; + requestBodies: { + BatchStatusRequest: { + content: { + "application/json": { + ids: number[]; + status: string; + }; + }; + }; + ChangePasswordRequest: { + content: { + "application/json": { + oldPassword: string; + newPassword: string; + }; + }; + }; + LoginRequest: { + content: { + "application/json": { + username: string; + password: string; + }; + }; + }; + MenuRequest: { + content: { + "application/json": components["schemas"]["MenuInput"]; + }; + }; + MenuSortRequest: { + content: { + "application/json": { + items: { + id: number; + sort: number; + }[]; + }; + }; + }; + MenuVisibleRequest: { + content: { + "application/json": { + visible: boolean; + }; + }; + }; + PermissionRequest: { + content: { + "application/json": components["schemas"]["PermissionInput"]; + }; + }; + RolePermissionRequest: { + content: { + "application/json": { + permissionIds: number[]; + }; + }; + }; + RoleRequest: { + content: { + "application/json": components["schemas"]["RoleInput"]; + }; + }; + StatusRequest: { + content: { + "application/json": { + status: string; + }; + }; + }; + UserRequest: { + content: { + "application/json": components["schemas"]["UserInput"]; + }; + }; + }; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + listAgencies: { + parameters: { + query?: { + page?: components["parameters"]["Page"]; + page_size?: components["parameters"]["PageSize"]; + keyword?: components["parameters"]["Keyword"]; + status?: components["parameters"]["Status"]; + region_id?: components["parameters"]["RegionId"]; + parent_bd_user_id?: components["parameters"]["ParentBdUserId"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["AgencyPageResponse"]; + }; + }; + createAgency: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + closeAgency: { + parameters: { + query?: never; + header?: never; + path: { + agency_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + setAgencyJoinEnabled: { + parameters: { + query?: never; + header?: never; + path: { + agency_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listH5Links: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateH5Links: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listApps: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listBDLeaders: { + parameters: { + query?: { + page?: components["parameters"]["Page"]; + page_size?: components["parameters"]["PageSize"]; + keyword?: components["parameters"]["Keyword"]; + status?: components["parameters"]["Status"]; + region_id?: components["parameters"]["RegionId"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["BDProfilePageResponse"]; + }; + }; + createBDLeader: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + setBDLeaderStatus: { + parameters: { + query?: never; + header?: never; + path: { + user_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listBDs: { + parameters: { + query?: { + page?: components["parameters"]["Page"]; + page_size?: components["parameters"]["PageSize"]; + keyword?: components["parameters"]["Keyword"]; + status?: components["parameters"]["Status"]; + region_id?: components["parameters"]["RegionId"]; + parent_leader_user_id?: components["parameters"]["ParentLeaderUserId"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["BDProfilePageResponse"]; + }; + }; + createBD: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + setBDStatus: { + parameters: { + query?: never; + header?: never; + path: { + user_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listCoinSellers: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + createCoinSeller: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + setCoinSellerStatus: { + parameters: { + query?: never; + header?: never; + path: { + user_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listCountries: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + createCountry: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + getCountry: { + parameters: { + query?: never; + header?: never; + path: { + country_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + deleteCountry: { + parameters: { + query?: never; + header?: never; + path: { + country_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateCountry: { + parameters: { + query?: never; + header?: never; + path: { + country_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + disableCountry: { + parameters: { + query?: never; + header?: never; + path: { + country_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + enableCountry: { + parameters: { + query?: never; + header?: never; + path: { + country_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + uploadImage: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + uploadFile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listGifts: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + createGift: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateGift: { + parameters: { + query?: never; + header?: never; + path: { + gift_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + disableGift: { + parameters: { + query?: never; + header?: never; + path: { + gift_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + enableGift: { + parameters: { + query?: never; + header?: never; + path: { + gift_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listHosts: { + parameters: { + query?: { + page?: components["parameters"]["Page"]; + page_size?: components["parameters"]["PageSize"]; + keyword?: components["parameters"]["Keyword"]; + status?: components["parameters"]["Status"]; + region_id?: components["parameters"]["RegionId"]; + agency_id?: components["parameters"]["AgencyId"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["HostProfilePageResponse"]; + }; + }; + listRegions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + createRegion: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + getRegion: { + parameters: { + query?: never; + header?: never; + path: { + region_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + disableRegion: { + parameters: { + query?: never; + header?: never; + path: { + region_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateRegion: { + parameters: { + query?: never; + header?: never; + path: { + region_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + replaceRegionCountries: { + parameters: { + query?: never; + header?: never; + path: { + region_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + enableRegion: { + parameters: { + query?: never; + header?: never; + path: { + region_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listResourceGrants: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + grantResourceGroup: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + grantResource: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listResourceGroups: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + createResourceGroup: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + getResourceGroup: { + parameters: { + query?: never; + header?: never; + path: { + group_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateResourceGroup: { + parameters: { + query?: never; + header?: never; + path: { + group_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + disableResourceGroup: { + parameters: { + query?: never; + header?: never; + path: { + group_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + enableResourceGroup: { + parameters: { + query?: never; + header?: never; + path: { + group_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateResourceGroupItems: { + parameters: { + query?: never; + header?: never; + path: { + group_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listResources: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + createResource: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + getResource: { + parameters: { + query?: never; + header?: never; + path: { + resource_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateResource: { + parameters: { + query?: never; + header?: never; + path: { + resource_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + disableResource: { + parameters: { + query?: never; + header?: never; + path: { + resource_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + enableResource: { + parameters: { + query?: never; + header?: never; + path: { + resource_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listRooms: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + deleteRoom: { + parameters: { + query?: never; + header?: never; + path: { + room_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateRoom: { + parameters: { + query?: never; + header?: never; + path: { + room_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + appListUsers: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + appGetUser: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + appUpdateUser: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + appBanUser: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + appSetPassword: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + appUnbanUser: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + changePassword: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: components["requestBodies"]["ChangePasswordRequest"]; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: components["requestBodies"]["LoginRequest"]; + responses: { + 200: components["responses"]["SessionResponse"]; + }; + }; + logout: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["SessionResponse"]; + }; + }; + refresh: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["SessionResponse"]; + }; + }; + dashboardOverview: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["DashboardOverviewResponse"]; + }; + }; + createUserExportJob: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listJobs: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + downloadJobArtifact: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + cancelJob: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listLoginLogs: { + parameters: { + query?: { + page?: components["parameters"]["Page"]; + page_size?: components["parameters"]["PageSize"]; + keyword?: components["parameters"]["Keyword"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["LogPageResponse"]; + }; + }; + exportLoginLogs: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description CSV */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listOperationLogs: { + parameters: { + query?: { + page?: components["parameters"]["Page"]; + page_size?: components["parameters"]["PageSize"]; + keyword?: components["parameters"]["Keyword"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["LogPageResponse"]; + }; + }; + exportOperationLogs: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description CSV */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + navigationMenus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["MenuListResponse"]; + }; + }; + listNotifications: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["NotificationListResponse"]; + }; + }; + deleteNotification: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + markNotificationRead: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["NotificationResponse"]; + }; + }; + markAllNotificationsRead: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["NotificationListResponse"]; + }; + }; + listPermissions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["PermissionListResponse"]; + }; + }; + createPermission: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: components["requestBodies"]["PermissionRequest"]; + responses: { + 200: components["responses"]["PermissionResponse"]; + }; + }; + deletePermission: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updatePermission: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: components["requestBodies"]["PermissionRequest"]; + responses: { + 200: components["responses"]["PermissionResponse"]; + }; + }; + syncPermissions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["PermissionListResponse"]; + }; + }; + listRoles: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["RoleListResponse"]; + }; + }; + createRole: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: components["requestBodies"]["RoleRequest"]; + responses: { + 200: components["responses"]["RoleResponse"]; + }; + }; + deleteRole: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateRole: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: components["requestBodies"]["RoleRequest"]; + responses: { + 200: components["responses"]["RoleResponse"]; + }; + }; + getRoleDataScopes: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + replaceRoleDataScopes: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + replaceRolePermissions: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: components["requestBodies"]["RolePermissionRequest"]; + responses: { + 200: components["responses"]["RoleResponse"]; + }; + }; + search: { + parameters: { + query?: { + keyword?: components["parameters"]["Keyword"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["SearchResponse"]; + }; + }; + listSystemMenus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["MenuListResponse"]; + }; + }; + createMenu: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: components["requestBodies"]["MenuRequest"]; + responses: { + 200: components["responses"]["MenuResponse"]; + }; + }; + deleteMenu: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateMenu: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: components["requestBodies"]["MenuRequest"]; + responses: { + 200: components["responses"]["MenuResponse"]; + }; + }; + updateMenuVisible: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: components["requestBodies"]["MenuVisibleRequest"]; + responses: { + 200: components["responses"]["MenuResponse"]; + }; + }; + sortMenus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: components["requestBodies"]["MenuSortRequest"]; + responses: { + 200: components["responses"]["MenuListResponse"]; + }; + }; + listUsers: { + parameters: { + query?: { + page?: components["parameters"]["Page"]; + page_size?: components["parameters"]["PageSize"]; + keyword?: components["parameters"]["Keyword"]; + role?: string; + status?: components["parameters"]["Status"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["UserPageResponse"]; + }; + }; + createUser: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: components["requestBodies"]["UserRequest"]; + responses: { + 200: components["responses"]["CreateUserResponse"]; + }; + }; + getUser: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["UserResponse"]; + }; + }; + updateUser: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: components["requestBodies"]["UserRequest"]; + responses: { + 200: components["responses"]["UserResponse"]; + }; + }; + resetUserPassword: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ResetPasswordResponse"]; + }; + }; + updateUserStatus: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: components["requestBodies"]["StatusRequest"]; + responses: { + 200: components["responses"]["UserResponse"]; + }; + }; + batchUpdateUserStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: components["requestBodies"]["BatchStatusRequest"]; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + exportUsers: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description CSV */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; +} diff --git a/src/shared/api/query.ts b/src/shared/api/query.ts new file mode 100644 index 0000000..6c4e229 --- /dev/null +++ b/src/shared/api/query.ts @@ -0,0 +1,15 @@ +import type { QueryParams } from "./request"; + +export function cleanQuery(query: QueryParams = {}) { + return Object.fromEntries( + Object.entries(query).filter(([, value]) => value !== undefined && value !== null && value !== "") + ); +} + +export function toPageQuery({ page, pageSize, ...query }: QueryParams & { page: number; pageSize: number }) { + return cleanQuery({ + ...query, + page, + page_size: pageSize + }); +} diff --git a/src/shared/api/regions.ts b/src/shared/api/regions.ts new file mode 100644 index 0000000..907ee17 --- /dev/null +++ b/src/shared/api/regions.ts @@ -0,0 +1,11 @@ +import { apiRequest } from "@/shared/api/request"; +import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import type { ApiList, PageQuery, RegionDto } from "@/shared/api/types"; + +export function listRegions(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listRegions; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listRegions), { + method: endpoint.method, + query + }); +} diff --git a/src/shared/api/request.test.ts b/src/shared/api/request.test.ts new file mode 100644 index 0000000..e4b74ee --- /dev/null +++ b/src/shared/api/request.test.ts @@ -0,0 +1,32 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { apiRequest, setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "./request"; + +afterEach(() => { + setAccessToken(""); + setRefreshHandler(null); + setUnauthorizedHandler(null); + vi.unstubAllGlobals(); +}); + +test("retries once after refreshing a stale permission token on 403", async () => { + setAccessToken("old-token"); + setRefreshHandler(async () => { + setAccessToken("new-token"); + return true; + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + if (vi.mocked(fetch).mock.calls.length === 1) { + return new Response(JSON.stringify({ code: 403, message: "没有操作权限" }), { status: 403 }); + } + return new Response(JSON.stringify({ code: 0, data: { ok: true } })); + }) + ); + + await expect(apiRequest("/v1/admin/countries", { body: {}, method: "POST" })).resolves.toEqual({ ok: true }); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(vi.mocked(fetch).mock.calls[0][1]?.headers).toMatchObject({ Authorization: "Bearer old-token" }); + expect(vi.mocked(fetch).mock.calls[1][1]?.headers).toMatchObject({ Authorization: "Bearer new-token" }); +}); diff --git a/src/shared/api/request.ts b/src/shared/api/request.ts new file mode 100644 index 0000000..187f98e --- /dev/null +++ b/src/shared/api/request.ts @@ -0,0 +1,170 @@ +import { API_BASE_URL } from "@/shared/config/env.js"; + +const TOKEN_KEY = "hyapp-admin.access-token"; +const APP_CODE_KEY = "hyapp-admin.app-code"; +const APP_CODE_HEADER = "X-App-Code"; + +let accessToken = window.localStorage.getItem(TOKEN_KEY) || ""; +let selectedAppCode = normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu"); +let refreshHandler: (() => Promise) | null = null; +let unauthorizedHandler: (() => void) | null = null; + +export type QueryValue = string | number | boolean | null | undefined; +export type QueryParams = Record; +export type HttpMethod = "DELETE" | "GET" | "PATCH" | "POST" | "PUT"; + +export interface ApiRequestOptions { + body?: TBody; + headers?: Record; + method?: HttpMethod; + query?: QueryParams; + raw?: boolean; + retry?: boolean; + skipAuth?: boolean; +} + +interface ApiEnvelope { + code?: number; + data?: TData; + message?: string; +} + +export function getAccessToken() { + return accessToken; +} + +export function setAccessToken(token: string) { + accessToken = token || ""; + if (accessToken) { + window.localStorage.setItem(TOKEN_KEY, accessToken); + } else { + window.localStorage.removeItem(TOKEN_KEY); + } +} + +export function getSelectedAppCode() { + return selectedAppCode; +} + +export function setSelectedAppCode(appCode: string) { + selectedAppCode = normalizeAppCode(appCode); + if (selectedAppCode) { + window.localStorage.setItem(APP_CODE_KEY, selectedAppCode); + } else { + window.localStorage.removeItem(APP_CODE_KEY); + } +} + +export function setRefreshHandler(handler: (() => Promise) | null) { + refreshHandler = handler; +} + +export function setUnauthorizedHandler(handler: (() => void) | null) { + unauthorizedHandler = handler; +} + +export async function apiRequest( + path: string, + options: ApiRequestOptions & { raw: true } +): Promise; +export async function apiRequest( + path: string, + options?: ApiRequestOptions & { raw?: false } +): Promise; +export async function apiRequest( + path: string, + options?: ApiRequestOptions +): Promise; +export async function apiRequest( + path: string, + options: ApiRequestOptions = {} +): Promise { + 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}`; + } + if (selectedAppCode && !requestHeaders[APP_CODE_HEADER]) { + requestHeaders[APP_CODE_HEADER] = selectedAppCode; + } + + 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 || response.status === 403) && 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(resolveErrorMessage(response, payload.message)); + } + + return payload.data as TData; +} + +function resolveErrorMessage(response: Response, message?: string) { + if (response.status === 404 && (!message || message.includes("404 page not found"))) { + return "接口不存在或后端服务未更新"; + } + return message || response.statusText || "请求失败"; +} + +function buildURL(path: string, query?: QueryParams) { + 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, String(value)); + } + }); + } + return url.toString(); +} + +async function readJSON(response: Response): Promise> { + const text = await response.text(); + if (!text) { + return {}; + } + try { + return JSON.parse(text) as ApiEnvelope; + } catch { + return { code: response.ok ? 0 : response.status, message: text, data: text as TData }; + } +} + +function normalizeAppCode(value: string) { + return value.trim().toLowerCase(); +} diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts new file mode 100644 index 0000000..f2eccf4 --- /dev/null +++ b/src/shared/api/types.ts @@ -0,0 +1,470 @@ +export interface ApiPage { + items: TItem[]; + page: number; + pageSize: number; + total: number; +} + +export interface ApiList { + items: TItem[]; + total: number; +} + +export type EntityId = number | string; + +export interface PageQuery { + [key: string]: boolean | number | string | null | undefined; + agency_id?: EntityId; + agencyId?: EntityId; + enabled?: boolean; + keyword?: string; + page?: number; + parent_bd_user_id?: EntityId; + parent_leader_user_id?: EntityId; + parentBdUserId?: EntityId; + parentLeaderUserId?: EntityId; + page_size?: number; + pageSize?: number; + region_id?: EntityId; + regionId?: EntityId; + role?: string; + status?: string; +} + +export interface IdName { + id: number; + name: string; +} + +export type StatusKind = "active" | "disabled" | "error" | "locked" | "running" | "stopped" | "warning"; +export type UserStatus = "active" | "disabled" | "locked"; + +export interface AdminUserDto { + account: string; + id: EntityId; + lastLogin?: string; + mfa?: string; + mfaEnabled?: boolean; + name: string; + role?: string; + roleIds?: number[]; + status: UserStatus; + team?: string; +} + +export interface UserFormPayload { + mfaEnabled?: boolean; + name: string; + password?: string; + roleIds: number[]; + status: UserStatus; + team?: string; + username?: string; +} + +export type UserUpdatePayload = Omit; + +export interface ResetPasswordResultDto { + initialPassword?: string; +} + +export interface CreateUserResultDto { + initialPassword?: string; +} + +export interface RoleDto { + code: string; + description?: string; + id: number; + name: string; + permissions?: PermissionDto[]; +} + +export interface RolePayload { + code: string; + description?: string; + name: string; + permissionIds?: number[]; +} + +export interface PermissionDto { + code: string; + description?: string; + id: number; + kind: "api" | "button" | "menu"; + name: string; +} + +export interface PermissionPayload { + code: string; + description?: string; + kind: PermissionDto["kind"]; + name: string; +} + +export interface MenuDto { + children?: MenuDto[]; + code: string; + icon?: string; + id: number; + label: string; + parentId?: number | null; + path?: string; + permissionCode?: string; + sort?: number; + visible?: boolean; +} + +export interface MenuPayload { + code: string; + icon?: string; + label: string; + parentId?: number | null; + path?: string; + permissionCode?: string; + sort?: number; + visible?: boolean; +} + +export interface MenuSortItem { + id: number; + sort?: number; +} + +export interface DashboardOverviewDto { + alerts?: Array<{ desc: string; name: string; time: string; type: string }>; + activeUsers: number; + disabledUsers: number; + lockedUsers: number; + logsToday: number; + menuTotal: number; + rolesTotal: number; + series?: { + notifications?: number[]; + operations?: number[]; + users?: number[]; + }; + unreadNotifications: number; + updatedAt?: string; + usersTotal: number; +} + +export interface LogDto { + action?: string; + createdAt?: string; + detail?: string; + id: number; + ip?: string; + message?: string; + resource?: string; + status: string; + username?: string; +} + +export interface NotificationDto { + content: string; + createdAt?: string; + id: number; + level: string; + readAt?: string | null; + title: string; +} + +export interface NotificationListDto { + items: NotificationDto[]; + unread: number; +} + +export interface SessionDto { + accessToken?: string; + permissions?: string[]; + user?: { + account?: string; + id?: number; + name?: string; + }; +} + +export interface LoginPayload { + password: string; + username: string; +} + +export interface ChangePasswordPayload { + newPassword: string; + oldPassword: string; +} + +export interface BDProfileDto { + createdAtMs?: number; + createdByUserId?: number; + parentLeaderUserId?: number; + regionId?: number; + role?: string; + status?: string; + updatedAtMs?: number; + userId: number; +} + +export interface AgencyDto { + agencyId: number; + createdAtMs?: number; + createdByUserId?: number; + joinEnabled?: boolean; + maxHosts?: number; + name?: string; + ownerUserId?: number; + parentBdUserId?: number; + regionId?: number; + status?: string; + updatedAtMs?: number; +} + +export interface HostProfileDto { + createdAtMs?: number; + currentAgencyId?: number; + currentMembershipId?: number; + firstBecameHostAtMs?: number; + regionId?: number; + source?: string; + status?: string; + updatedAtMs?: number; + userId: number; +} + +export interface CoinSellerDto { + avatar?: string; + createdAtMs?: number; + createdByUserId?: number; + displayUserId?: string; + merchantAssetType?: string; + merchantBalance?: number; + regionId?: number; + regionName?: string; + status?: string; + updatedAtMs?: number; + userId: number; + username?: string; +} + +export interface AppUserDto { + avatar?: string; + coin?: number; + country?: string; + countryDisplayName?: string; + countryName?: string; + createdAtMs?: number; + diamond?: number; + displayUserId?: string; + gender?: string; + lastActiveAtMs?: number; + regionId?: number; + regionName?: string; + status?: string; + updatedAtMs?: number; + userId: string; + username?: string; +} + +export interface AppUserUpdatePayload { + avatar?: string; + country?: string; + gender?: string; + username?: string; +} + +export interface AppUserPasswordPayload { + password: string; +} + +export interface RoomDto { + coverUrl?: string; + createdAtMs?: number; + heat?: number; + hostUserId?: string; + mode?: string; + occupiedSeatCount?: number; + onlineCount?: number; + owner?: RoomOwnerDto; + ownerUserId?: string; + regionName?: string; + roomId: string; + seatCount?: number; + sortScore?: number; + status?: string; + title?: string; + updatedAtMs?: number; + visibleRegionId?: number; +} + +export interface RoomOwnerDto { + avatar?: string; + displayUserId?: string; + userId?: string; + username?: string; +} + +export interface RoomUpdatePayload { + coverUrl?: string; + description?: string; + mode?: string; + status?: string; + title?: string; + visibleRegionId?: number; +} + +export interface UploadResultDto { + content_type?: string; + contentType?: string; + object_key?: string; + objectKey?: string; + size_bytes?: number; + sizeBytes?: number; + url: string; +} + +export interface AdminAppDto { + appCode: string; + appId: number; + appName: string; + createdAtMs?: number; + packageName?: string; + platform?: string; + status?: string; + updatedAtMs?: number; +} + +export interface H5LinkConfigDto { + key: string; + label: string; + updatedAtMs?: number; + url?: string; +} + +export interface H5LinkConfigPayload { + key: string; + url?: string; +} + +export interface H5LinkConfigUpdatePayload { + items: H5LinkConfigPayload[]; +} + +export interface AgencyMembershipDto { + agencyId?: number; + createdAtMs?: number; + endedAtMs?: number; + endedByUserId?: number; + endedReason?: string; + hostUserId?: number; + joinedAtMs?: number; + membershipId: number; + membershipType?: string; + regionId?: number; + status?: string; + updatedAtMs?: number; +} + +export interface CreateAgencyResultDto { + agency?: AgencyDto; + hostProfile?: HostProfileDto; + membership?: AgencyMembershipDto; +} + +export interface HostCommandPayload { + commandId: string; + reason?: string; +} + +export interface CreateBDLeaderPayload extends HostCommandPayload { + regionId: number; + targetUserId: number; +} + +export interface CreateBDPayload extends HostCommandPayload { + parentLeaderUserId: number; + targetUserId: number; +} + +export interface BDStatusPayload extends HostCommandPayload { + status: string; +} + +export interface CreateCoinSellerPayload extends HostCommandPayload { + targetUserId: number; +} + +export type CoinSellerStatusPayload = BDStatusPayload; + +export interface CreateAgencyPayload extends HostCommandPayload { + joinEnabled: boolean; + maxHosts?: number; + name: string; + ownerUserId: number; + parentBdUserId: number; +} + +export interface AgencyJoinEnabledPayload extends HostCommandPayload { + joinEnabled: boolean; +} + +export interface CountryDto { + countryCode: string; + countryDisplayName?: string; + countryId: number; + countryName?: string; + createdAtMs?: number; + enabled?: boolean; + flag?: string; + isoAlpha3?: string; + isoNumeric?: string; + phoneCountryCode?: string; + sortOrder?: number; + updatedAtMs?: number; +} + +export interface CountryPayload { + countryCode?: string; + countryDisplayName: string; + countryName: string; + enabled?: boolean; + flag?: string; + isoAlpha3?: string; + isoNumeric?: string; + phoneCountryCode?: string; + sortOrder?: number; +} + +export type CountryUpdatePayload = Omit; + +export interface RegionDto { + countries?: string[]; + createdAtMs?: number; + name: string; + regionCode: string; + regionId: number; + sortOrder?: number; + status?: string; + updatedAtMs?: number; +} + +export interface RegionPayload { + countries?: string[]; + name: string; + regionCode: string; + sortOrder?: number; +} + +export type RegionUpdatePayload = Omit; + +export interface RegionCountriesPayload { + countries: string[]; +} + +export interface SearchResultDto { + id: EntityId; + subtitle?: string; + target?: string; + title: string; + type: string; +} diff --git a/src/shared/api/upload.test.ts b/src/shared/api/upload.test.ts new file mode 100644 index 0000000..c0b70cc --- /dev/null +++ b/src/shared/api/upload.test.ts @@ -0,0 +1,39 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { setAccessToken } from "@/shared/api/request"; +import { uploadFile, uploadImage } from "@/shared/api/upload"; + +afterEach(() => { + setAccessToken(""); + vi.unstubAllGlobals(); +}); + +test("uploadImage sends multipart data to generated image endpoint", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { url: "https://media.haiyihy.com/admin/images/a.png" } }))) + ); + + const result = await uploadImage(new File(["image"], "avatar.png", { type: "image/png" })); + const [url, init] = vi.mocked(fetch).mock.calls[0]; + + expect(result.url).toContain("/admin/images/"); + expect(String(url)).toContain("/api/v1/admin/files/image/upload"); + expect(init?.method).toBe("POST"); + expect(init?.body).toBeInstanceOf(FormData); + expect(init?.headers).not.toHaveProperty("Content-Type"); +}); + +test("uploadFile sends multipart data to generated file endpoint", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { url: "https://media.haiyihy.com/admin/files/a.pdf" } }))) + ); + + await uploadFile(new File(["file"], "doc.pdf", { type: "application/pdf" })); + const [url, init] = vi.mocked(fetch).mock.calls[0]; + + expect(String(url)).toContain("/api/v1/admin/files/upload"); + expect(init?.method).toBe("POST"); + expect(init?.body).toBeInstanceOf(FormData); + expect(init?.headers).not.toHaveProperty("Content-Type"); +}); diff --git a/src/shared/api/upload.ts b/src/shared/api/upload.ts new file mode 100644 index 0000000..7f4e70c --- /dev/null +++ b/src/shared/api/upload.ts @@ -0,0 +1,25 @@ +import { apiRequest } from "@/shared/api/request"; +import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import type { UploadResultDto } from "@/shared/api/types"; + +export function uploadFile(file: File): Promise { + const endpoint = API_ENDPOINTS.uploadFile; + const formData = new FormData(); + formData.append("file", file); + + return apiRequest(apiEndpointPath(API_OPERATIONS.uploadFile), { + body: formData, + method: endpoint.method + }); +} + +export function uploadImage(file: File): Promise { + const endpoint = API_ENDPOINTS.uploadImage; + const formData = new FormData(); + formData.append("image", file); + + return apiRequest(apiEndpointPath(API_OPERATIONS.uploadImage), { + body: formData, + method: endpoint.method + }); +} diff --git a/src/components/charts/BarChart.jsx b/src/shared/charts/BarChart.jsx similarity index 54% rename from src/components/charts/BarChart.jsx rename to src/shared/charts/BarChart.jsx index 505175b..2af85b9 100644 --- a/src/components/charts/BarChart.jsx +++ b/src/shared/charts/BarChart.jsx @@ -1,18 +1,26 @@ import { EChart } from "./EChart.jsx"; +import { readCssToken } from "@/shared/utils/cssTokens.js"; export function BarChart({ series }) { return ; } function createBarOption(series) { + const tooltipBg = readCssToken("--chart-tooltip-bg"); + const tooltipBorder = readCssToken("--chart-tooltip-border"); + const tooltipText = readCssToken("--chart-tooltip-text"); + const gridColor = readCssToken("--chart-grid"); + const danger = readCssToken("--chart-red"); + const warning = readCssToken("--chart-orange"); + return { animationDuration: 450, grid: { left: 8, right: 8, top: 16, bottom: 10, containLabel: false }, tooltip: { trigger: "axis", - backgroundColor: "#ffffff", - borderColor: "#d8e0ec", - textStyle: { color: "#0f172a" } + backgroundColor: tooltipBg, + borderColor: tooltipBorder, + textStyle: { color: tooltipText } }, xAxis: { type: "category", @@ -22,7 +30,7 @@ function createBarOption(series) { yAxis: { type: "value", show: false, - splitLine: { show: true, lineStyle: { color: "rgba(100, 116, 139, 0.14)" } } + splitLine: { show: true, lineStyle: { color: gridColor } } }, series: [ { @@ -30,14 +38,14 @@ function createBarOption(series) { type: "bar", data: series.map((value) => Math.max(1, Math.round(value * 0.45))), barWidth: 8, - itemStyle: { color: "#dc2626", borderRadius: [4, 4, 0, 0] } + itemStyle: { color: danger, borderRadius: [4, 4, 0, 0] } }, { name: "警告", type: "bar", data: series, barWidth: 8, - itemStyle: { color: "#d97706", borderRadius: [4, 4, 0, 0] } + itemStyle: { color: warning, borderRadius: [4, 4, 0, 0] } } ] }; diff --git a/src/components/charts/ChartCard.jsx b/src/shared/charts/ChartCard.jsx similarity index 52% rename from src/components/charts/ChartCard.jsx rename to src/shared/charts/ChartCard.jsx index b04ff9b..19bcb8c 100644 --- a/src/components/charts/ChartCard.jsx +++ b/src/shared/charts/ChartCard.jsx @@ -1,15 +1,15 @@ -import { Card } from "../base/Card.jsx"; -import { SegmentControl } from "../base/SegmentControl.jsx"; +import { Card } from "@/shared/ui/Card.jsx"; +import { SegmentControl } from "@/shared/ui/SegmentControl.jsx"; import { LineChart } from "./LineChart.jsx"; -export function ChartCard({ color, compact = false, series, title }) { +export function ChartCard({ colorToken, compact = false, series, title }) { return (
{title}
- +
); } diff --git a/src/components/charts/EChart.jsx b/src/shared/charts/EChart.jsx similarity index 100% rename from src/components/charts/EChart.jsx rename to src/shared/charts/EChart.jsx diff --git a/src/components/charts/LineChart.jsx b/src/shared/charts/LineChart.jsx similarity index 57% rename from src/components/charts/LineChart.jsx rename to src/shared/charts/LineChart.jsx index 08288d8..a957001 100644 --- a/src/components/charts/LineChart.jsx +++ b/src/shared/charts/LineChart.jsx @@ -1,18 +1,25 @@ import { EChart } from "./EChart.jsx"; +import { readCssToken } from "@/shared/utils/cssTokens.js"; -export function LineChart({ color, series }) { +export function LineChart({ colorToken = "--chart-blue", series }) { + const color = readCssToken(colorToken); return ; } function createLineOption(color, series) { + const tooltipBg = readCssToken("--chart-tooltip-bg"); + const tooltipBorder = readCssToken("--chart-tooltip-border"); + const tooltipText = readCssToken("--chart-tooltip-text"); + const gridColor = readCssToken("--chart-grid"); + return { animationDuration: 450, grid: { left: 8, right: 8, top: 14, bottom: 8, containLabel: false }, tooltip: { trigger: "axis", - backgroundColor: "#ffffff", - borderColor: "#d8e0ec", - textStyle: { color: "#0f172a" } + backgroundColor: tooltipBg, + borderColor: tooltipBorder, + textStyle: { color: tooltipText } }, xAxis: { type: "category", @@ -25,7 +32,7 @@ function createLineOption(color, series) { min: 0, max: 100, show: false, - splitLine: { show: true, lineStyle: { color: "rgba(100, 116, 139, 0.16)" } } + splitLine: { show: true, lineStyle: { color: gridColor } } }, series: [ { diff --git a/src/components/charts/StatusDonut.jsx b/src/shared/charts/StatusDonut.jsx similarity index 69% rename from src/components/charts/StatusDonut.jsx rename to src/shared/charts/StatusDonut.jsx index e4aaf46..8644b5d 100644 --- a/src/components/charts/StatusDonut.jsx +++ b/src/shared/charts/StatusDonut.jsx @@ -1,4 +1,5 @@ import { EChart } from "./EChart.jsx"; +import { readCssToken } from "@/shared/utils/cssTokens.js"; export function StatusDonut({ stats }) { return ( @@ -14,13 +15,20 @@ export function StatusDonut({ stats }) { } function createPieOption(stats) { + const tooltipBg = readCssToken("--chart-tooltip-bg"); + const tooltipBorder = readCssToken("--chart-tooltip-border"); + const tooltipText = readCssToken("--chart-tooltip-text"); + const success = readCssToken("--chart-green"); + const danger = readCssToken("--chart-red"); + const stopped = readCssToken("--stopped"); + return { animationDuration: 450, tooltip: { trigger: "item", - backgroundColor: "#ffffff", - borderColor: "#d8e0ec", - textStyle: { color: "#0f172a" } + backgroundColor: tooltipBg, + borderColor: tooltipBorder, + textStyle: { color: tooltipText } }, series: [ { @@ -31,9 +39,9 @@ function createPieOption(stats) { label: { show: false }, labelLine: { show: false }, data: [ - { name: "运行中", value: stats.running, itemStyle: { color: "#16a34a" } }, - { name: "异常/警告", value: stats.error + stats.warning, itemStyle: { color: "#dc2626" } }, - { name: "已停止", value: stats.stopped, itemStyle: { color: "#64748b" } } + { name: "运行中", value: stats.running, itemStyle: { color: success } }, + { name: "异常/警告", value: stats.error + stats.warning, itemStyle: { color: danger } }, + { name: "已停止", value: stats.stopped, itemStyle: { color: stopped } } ] } ] diff --git a/src/config/env.js b/src/shared/config/env.js similarity index 100% rename from src/config/env.js rename to src/shared/config/env.js diff --git a/src/shared/forms/validation.ts b/src/shared/forms/validation.ts new file mode 100644 index 0000000..e95da19 --- /dev/null +++ b/src/shared/forms/validation.ts @@ -0,0 +1,27 @@ +import type { ZodIssue, ZodType } from "zod"; + +export class FormValidationError extends Error { + fieldErrors: Record; + issues: ZodIssue[]; + + constructor(issues: ZodIssue[]) { + super(issues[0]?.message || "表单校验失败"); + this.name = "FormValidationError"; + this.issues = issues; + this.fieldErrors = issues.reduce>((errors, issue) => { + const field = issue.path.join("."); + if (field && !errors[field]) { + errors[field] = issue.message; + } + return errors; + }, {}); + } +} + +export function parseForm(schema: ZodType, value: unknown): TValue { + const result = schema.safeParse(value); + if (result.success) { + return result.data; + } + throw new FormValidationError(result.error.issues); +} diff --git a/src/shared/hooks/useAdminMutation.js b/src/shared/hooks/useAdminMutation.js new file mode 100644 index 0000000..92298bb --- /dev/null +++ b/src/shared/hooks/useAdminMutation.js @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +export function useAdminMutation(mutationFn, options = {}) { + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: (args) => mutationFn(...args), + onSuccess: async (data, variables, context) => { + if (options.invalidateQueries) { + const keys = Array.isArray(options.invalidateQueries[0]) + ? options.invalidateQueries + : [options.invalidateQueries]; + await Promise.all(keys.map((queryKey) => queryClient.invalidateQueries({ queryKey }))); + } + if (options.onSuccess) { + await options.onSuccess(data, variables, context); + } + } + }); + + return { + error: mutation.error ? mutation.error.message || "操作失败" : "", + loading: mutation.isPending, + mutate: (...args) => mutation.mutateAsync(args) + }; +} diff --git a/src/shared/hooks/useAdminQuery.js b/src/shared/hooks/useAdminQuery.js new file mode 100644 index 0000000..a3821b1 --- /dev/null +++ b/src/shared/hooks/useAdminQuery.js @@ -0,0 +1,47 @@ +import { useCallback, useId } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +export function useAdminQuery(queryFn, options = {}) { + const defaultQueryId = useId(); + + const { + enabled = true, + errorMessage = "请求失败", + keepPreviousData = false, + initialData = null, + queryKey = ["admin-query", defaultQueryId], + refetchInterval, + staleTime + } = options; + const queryClient = useQueryClient(); + const query = useQuery({ + enabled, + placeholderData: keepPreviousData ? (previousData) => previousData ?? initialData : initialData, + queryFn, + queryKey, + refetchInterval, + staleTime + }); + + const reload = useCallback(async () => { + const result = await query.refetch(); + return result.data ?? null; + }, [query]); + + const setData = useCallback( + (updater) => { + queryClient.setQueryData(queryKey, updater); + }, + [queryClient, queryKey] + ); + + const error = query.error ? query.error.message || errorMessage : ""; + + return { + data: query.data, + error, + loading: query.isLoading || query.isFetching, + reload, + setData + }; +} diff --git a/src/shared/hooks/useAdminQuery.test.jsx b/src/shared/hooks/useAdminQuery.test.jsx new file mode 100644 index 0000000..a70079a --- /dev/null +++ b/src/shared/hooks/useAdminQuery.test.jsx @@ -0,0 +1,26 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { createAdminQueryClient } from "@/shared/query/client"; +import { useAdminQuery } from "./useAdminQuery.js"; + +test("reuses cached query data by query key", async () => { + const queryClient = createAdminQueryClient(); + const fetcher = vi.fn(async () => ({ value: 1 })); + const wrapper = ({ children }) => {children}; + + const queryOptions = { initialData: null, queryKey: ["cache-test"], staleTime: 60000 }; + const first = renderHook(() => useAdminQuery(fetcher, queryOptions), { wrapper }); + + await waitFor(() => { + expect(first.result.current.data).toEqual({ value: 1 }); + }); + + renderHook(() => useAdminQuery(fetcher, queryOptions), { wrapper }); + + await waitFor(() => { + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + queryClient.clear(); +}); diff --git a/src/shared/hooks/usePaginatedQuery.js b/src/shared/hooks/usePaginatedQuery.js new file mode 100644 index 0000000..046d433 --- /dev/null +++ b/src/shared/hooks/usePaginatedQuery.js @@ -0,0 +1,20 @@ +import { useCallback, useMemo } from "react"; +import { toPageQuery } from "@/shared/api/query"; +import { useAdminQuery } from "./useAdminQuery.js"; + +const emptyPage = { items: [], total: 0, page: 1, pageSize: 10 }; + +export function usePaginatedQuery({ errorMessage, fetcher, filters = {}, page, pageSize = 10, queryKey }) { + const requestQuery = useMemo( + () => toPageQuery({ ...filters, page, pageSize }), + [filters, page, pageSize] + ); + const queryFn = useCallback(() => fetcher(requestQuery), [fetcher, requestQuery]); + + return useAdminQuery(queryFn, { + errorMessage, + initialData: emptyPage, + keepPreviousData: true, + queryKey: queryKey || ["paginated", requestQuery] + }); +} diff --git a/src/shared/hooks/useRefreshSignal.js b/src/shared/hooks/useRefreshSignal.js new file mode 100644 index 0000000..584be94 --- /dev/null +++ b/src/shared/hooks/useRefreshSignal.js @@ -0,0 +1,28 @@ +import { createContext, createElement, useCallback, useContext, useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +const RefreshContext = createContext(null); + +export function RefreshProvider({ children }) { + const [refreshKey, setRefreshKey] = useState(0); + const queryClient = useQueryClient(); + const refresh = useCallback(() => { + setRefreshKey((current) => current + 1); + queryClient.invalidateQueries(); + }, [queryClient]); + const value = useMemo(() => ({ refresh, refreshKey }), [refresh, refreshKey]); + + return createElement(RefreshContext.Provider, { value }, children); +} + +export function useRefreshSignal() { + const context = useContext(RefreshContext); + if (!context) { + throw new Error("useRefreshSignal must be used inside RefreshProvider"); + } + return context; +} + +export function useRefreshKey() { + return useRefreshSignal().refreshKey; +} diff --git a/src/shared/hooks/useRegionOptions.js b/src/shared/hooks/useRegionOptions.js new file mode 100644 index 0000000..d8f4661 --- /dev/null +++ b/src/shared/hooks/useRegionOptions.js @@ -0,0 +1,31 @@ +import { useCallback, useMemo } from "react"; +import { listRegions } from "@/shared/api/regions"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; + +const emptyData = { items: [], total: 0 }; + +export function useRegionOptions() { + const queryFn = useCallback(() => listRegions(), []); + const { data = emptyData, loading: loadingRegions } = useAdminQuery(queryFn, { + errorMessage: "加载区域选项失败", + initialData: emptyData, + queryKey: ["regions", "options"], + staleTime: 5 * 60 * 1000 + }); + + const regionOptions = useMemo(() => { + return (data?.items || []).map((region) => ({ + label: formatRegionLabel(region), + name: region.name, + regionCode: region.regionCode, + regionId: region.regionId, + value: String(region.regionId) + })); + }, [data?.items]); + + return { loadingRegions, regionOptions }; +} + +function formatRegionLabel(region) { + return [region.name, region.regionCode, region.regionId].filter(Boolean).join(" · "); +} diff --git a/src/shared/query/QueryProvider.jsx b/src/shared/query/QueryProvider.jsx new file mode 100644 index 0000000..a8a6e87 --- /dev/null +++ b/src/shared/query/QueryProvider.jsx @@ -0,0 +1,6 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "./client"; + +export function QueryProvider({ children }) { + return {children}; +} diff --git a/src/shared/query/client.ts b/src/shared/query/client.ts new file mode 100644 index 0000000..6a0ea6f --- /dev/null +++ b/src/shared/query/client.ts @@ -0,0 +1,19 @@ +import { QueryClient } from "@tanstack/react-query"; + +const defaultOptions = { + queries: { + gcTime: 10 * 60 * 1000, + refetchOnWindowFocus: false, + retry: 1, + staleTime: 30 * 1000 + }, + mutations: { + retry: 0 + } +}; + +export function createAdminQueryClient() { + return new QueryClient({ defaultOptions }); +} + +export const queryClient = createAdminQueryClient(); diff --git a/src/shared/ui/AdminFormDialog.jsx b/src/shared/ui/AdminFormDialog.jsx new file mode 100644 index 0000000..0314038 --- /dev/null +++ b/src/shared/ui/AdminFormDialog.jsx @@ -0,0 +1,83 @@ +import CircularProgress from "@mui/material/CircularProgress"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import { Button } from "@/shared/ui/Button.jsx"; +import styles from "@/shared/ui/AdminFormDialog.module.css"; + +export function AdminFormDialog({ + children, + disabled = false, + loading = false, + onClose, + onSubmit, + open, + size = "standard", + submitDisabled, + submitLabel = "提交", + submitVariant = "primary", + title +}) { + const isSubmitDisabled = submitDisabled ?? (disabled || loading); + + return ( + +
+ {title} + {children} + + + + +
+
+ ); +} + +export function AdminFormSection({ actions, children, title }) { + return ( +
+ {title || actions ? ( +
+ {title ?
{title}
: } + {actions ?
{actions}
: null} +
+ ) : null} + {children} +
+ ); +} + +export function AdminFormFieldGrid({ children, columns }) { + return ( +
+ {children} +
+ ); +} + +export function AdminFormInlineActions({ children }) { + return
{children}
; +} + +export function AdminFormList({ children }) { + return
{children}
; +} + +export function AdminFormListRow({ children, columns }) { + return ( +
+ {children} +
+ ); +} diff --git a/src/shared/ui/AdminFormDialog.module.css b/src/shared/ui/AdminFormDialog.module.css new file mode 100644 index 0000000..2211a46 --- /dev/null +++ b/src/shared/ui/AdminFormDialog.module.css @@ -0,0 +1,161 @@ +.dialog { + --admin-form-dialog-width: 600px; + --admin-form-content-x: var(--space-6); + --admin-form-field-width: 240px; + --admin-form-wide-width: calc(var(--admin-form-dialog-width) - var(--admin-form-content-x) - var(--admin-form-content-x)); +} + +.paper { + width: min(var(--admin-form-dialog-width), calc(100vw - 32px)) !important; + max-width: min(var(--admin-form-dialog-width), calc(100vw - 32px)) !important; + border-radius: var(--radius-card); +} + +.compact { + --admin-form-dialog-width: 520px; + --admin-form-field-width: 220px; +} + +.wide { + --admin-form-dialog-width: 680px; + --admin-form-field-width: 220px; +} + +.form { + display: flex; + max-height: calc(100vh - 64px); + flex-direction: column; +} + +.title { + padding: var(--space-5) var(--admin-form-content-x) var(--space-3); + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 760; + line-height: var(--admin-line-height); +} + +.content { + display: flex; + flex: 0 1 auto; + flex-wrap: wrap; + overflow: auto; + align-items: start; + gap: var(--space-3); + padding: var(--space-2) var(--admin-form-content-x) var(--space-4) !important; +} + +.content > :global(.MuiTextField-root), +.content > :global(.MuiFormControl-root) { + flex: 0 0 min(100%, var(--admin-form-field-width)); + width: min(100%, var(--admin-form-field-width)); + max-width: 100%; +} + +.content > :global(.MuiTextField-root:has(.MuiInputBase-multiline)), +.content > :global(.MuiFormControl-root:has(.MuiInputBase-multiline)), +.content > :global(.MuiAutocomplete-root), +.content > :global(.MuiFormControlLabel-root), +.content > :not(:global(.MuiTextField-root)):not(:global(.MuiFormControl-root)):not(:global(.MuiAutocomplete-root)):not(:global(.MuiFormControlLabel-root)) { + flex: 0 0 min(100%, var(--admin-form-wide-width)); + width: min(100%, var(--admin-form-wide-width)); + max-width: 100%; +} + +.section { + display: grid; + gap: var(--space-3); +} + +.sectionHeader { + display: flex; + min-height: var(--control-height); + align-items: center; + justify-content: flex-start; + flex-wrap: wrap; + gap: var(--space-3); +} + +.sectionTitle { + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 700; +} + +.sectionActions, +.inlineActions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.fieldGrid { + display: grid; + grid-template-columns: var(--admin-form-grid-columns, repeat(auto-fit, minmax(180px, 1fr))); + gap: var(--space-3); + width: min(100%, var(--admin-form-wide-width)); + max-width: 100%; +} + +.fieldGrid > :global(.MuiTextField-root), +.fieldGrid > :global(.MuiFormControl-root), +.fieldGrid > :global(.MuiAutocomplete-root) { + width: 100%; +} + +.list { + display: grid; + gap: var(--space-2); +} + +.listRow { + display: grid; + grid-template-columns: var(--admin-form-list-columns, minmax(180px, 1fr) minmax(112px, 0.55fr) minmax(92px, 0.4fr) auto); + align-items: start; + gap: var(--space-2); + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-card-strong); +} + +.listRow > :global(.MuiTextField-root), +.listRow > :global(.MuiFormControl-root) { + width: 100%; +} + +.actions { + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-3) var(--admin-form-content-x) var(--space-5); +} + +@media (max-width: 760px) { + .dialog { + --admin-form-content-x: var(--space-4); + --admin-form-wide-width: calc(100vw - 24px - var(--admin-form-content-x) - var(--admin-form-content-x)); + } + + .paper { + width: calc(100vw - 24px) !important; + max-width: calc(100vw - 24px) !important; + } + + .content { + padding-right: var(--admin-form-content-x) !important; + padding-left: var(--admin-form-content-x) !important; + } + + .content > :global(.MuiTextField-root), + .content > :global(.MuiFormControl-root) { + width: 100%; + } + + .listRow { + grid-template-columns: 1fr; + } + + .fieldGrid { + grid-template-columns: 1fr !important; + } +} diff --git a/src/shared/ui/AdminListLayout.jsx b/src/shared/ui/AdminListLayout.jsx new file mode 100644 index 0000000..087adef --- /dev/null +++ b/src/shared/ui/AdminListLayout.jsx @@ -0,0 +1,70 @@ +import Tooltip from "@mui/material/Tooltip"; +import { IconButton } from "@/shared/ui/IconButton.jsx"; +import { SearchBox } from "@/shared/ui/SearchBox.jsx"; +import { StatusSelect } from "@/shared/ui/StatusSelect.jsx"; +import styles from "@/shared/ui/AdminListLayout.module.css"; + +export function AdminListPage({ children }) { + return ( +
+
{children}
+
+ ); +} + +export function AdminListToolbar({ actions, filters }) { + return ( +
+
+
{filters}
+
+ {actions ?
{actions}
: null} +
+ ); +} + +export function AdminListBody({ children }) { + return
{children}
; +} + +export function AdminRowActions({ children }) { + return
{children}
; +} + +export function AdminSearchBox(props) { + return ; +} + +export function AdminFilterSelect(props) { + return ; +} + +export function AdminActionIconButton({ children, label, primary = false, tooltip = label, ...props }) { + return ( + + + + {children} + + + + ); +} + +export const adminListClasses = styles; + +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)" + } +}; diff --git a/src/shared/ui/AdminListLayout.module.css b/src/shared/ui/AdminListLayout.module.css new file mode 100644 index 0000000..5e39a2e --- /dev/null +++ b/src/shared/ui/AdminListLayout.module.css @@ -0,0 +1,135 @@ +.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; +} + +.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; +} + +.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); +} + +.rowActions { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.dragRow { + cursor: grab; +} + +.dragRow:active { + cursor: grabbing; +} + +.dragRowActive { + background: var(--primary-hover); +} + +.dragHandle { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + cursor: grab; +} + +@media (max-width: 760px) { + .toolbar, + .toolbarLeft, + .filters { + align-items: stretch; + flex-direction: column; + } + + .toolbarActions { + align-self: flex-end; + } + + .search, + .statusSelect { + flex: 1 1 auto; + width: 100%; + } +} diff --git a/src/components/base/Button.jsx b/src/shared/ui/Button.jsx similarity index 71% rename from src/components/base/Button.jsx rename to src/shared/ui/Button.jsx index 5a2bcea..5dd0ceb 100644 --- a/src/components/base/Button.jsx +++ b/src/shared/ui/Button.jsx @@ -12,7 +12,7 @@ export function Button({ children, className = "", variant, ...props }) { className={["button", variantClass, className].filter(Boolean).join(" ")} color={color} disableElevation - sx={{ ...toneSx, ...sx }} + sx={{ height: "var(--control-height)", minHeight: "var(--control-height)", ...toneSx, ...sx }} type="button" variant={muiVariant} {...buttonProps} @@ -26,10 +26,10 @@ function getToneSx(variant) { if (variant === "success") { return { color: "var(--success)", - borderColor: "rgba(46, 219, 95, 0.38)", + borderColor: "var(--success-border)", "&:hover": { - borderColor: "rgba(46, 219, 95, 0.72)", - backgroundColor: "rgba(46, 219, 95, 0.1)" + borderColor: "var(--success-border-strong)", + backgroundColor: "var(--success-surface)" } }; } @@ -37,10 +37,10 @@ function getToneSx(variant) { if (variant === "danger") { return { color: "var(--danger)", - borderColor: "rgba(255, 77, 79, 0.38)", + borderColor: "var(--danger-border)", "&:hover": { - borderColor: "rgba(255, 77, 79, 0.72)", - backgroundColor: "rgba(255, 77, 79, 0.1)" + borderColor: "var(--danger-border-strong)", + backgroundColor: "var(--danger-surface)" } }; } diff --git a/src/components/base/Card.jsx b/src/shared/ui/Card.jsx similarity index 100% rename from src/components/base/Card.jsx rename to src/shared/ui/Card.jsx diff --git a/src/components/base/ConfirmProvider.jsx b/src/shared/ui/ConfirmProvider.jsx similarity index 100% rename from src/components/base/ConfirmProvider.jsx rename to src/shared/ui/ConfirmProvider.jsx diff --git a/src/components/base/DataState.jsx b/src/shared/ui/DataState.jsx similarity index 100% rename from src/components/base/DataState.jsx rename to src/shared/ui/DataState.jsx diff --git a/src/shared/ui/DataTable.jsx b/src/shared/ui/DataTable.jsx new file mode 100644 index 0000000..6908796 --- /dev/null +++ b/src/shared/ui/DataTable.jsx @@ -0,0 +1,64 @@ +export function DataTable({ + className = "", + columns, + context, + emptyLabel = "当前无数据", + getRowProps, + items, + minWidth = "980px", + rowKey, + tableClassName = "", + title, + total +}) { + const gridTemplate = columns.map((column) => column.width || "minmax(120px, 1fr)").join(" "); + + return ( +
+ {title || total !== undefined ? ( +
+
{title}
+ {total !== undefined ?
共 {total} 条
: null} +
+ ) : null} +
+
+
+ {columns.map((column) => ( +
+ {column.header || column.label} +
+ ))} +
+ {items.length ? ( + items.map((item, index) => { + const rowProps = getRowProps ? getRowProps(item, index) : {}; + const { className: rowClassName = "", ...restRowProps } = rowProps; + + return ( +
+ {columns.map((column) => ( +
+ {column.render ? column.render(item, index, context) : displayValue(item[column.key])} +
+ ))} +
+ ); + }) + ) : ( +
+
{emptyLabel}
+
+ )} +
+
+
+ ); +} + +function displayValue(value) { + return value === 0 || value === false || value ? value : "-"; +} diff --git a/src/components/base/FilterChip.jsx b/src/shared/ui/FilterChip.jsx similarity index 54% rename from src/components/base/FilterChip.jsx rename to src/shared/ui/FilterChip.jsx index a9491fd..1576cf6 100644 --- a/src/components/base/FilterChip.jsx +++ b/src/shared/ui/FilterChip.jsx @@ -9,14 +9,14 @@ export function FilterChip({ active, label, onClick }) { onClick={onClick} size="small" sx={{ - height: 30, - borderColor: active ? "rgba(37, 99, 235, 0.7)" : "var(--border)", - backgroundColor: active ? "rgba(37, 99, 235, 0.1)" : "transparent", + height: "var(--status-height)", + borderColor: active ? "var(--primary-border-strong)" : "var(--border)", + backgroundColor: active ? "var(--primary-surface)" : "transparent", color: active ? "var(--text-primary)" : "var(--text-tertiary)", - fontSize: 12, + fontSize: "var(--admin-font-size)", "&:hover": { - borderColor: "rgba(37, 99, 235, 0.7)", - backgroundColor: active ? "rgba(37, 99, 235, 0.14)" : "rgba(37, 99, 235, 0.08)" + borderColor: "var(--primary-border-strong)", + backgroundColor: active ? "var(--primary-surface-strong)" : "var(--primary-hover)" } }} variant="outlined" diff --git a/src/components/base/IconButton.jsx b/src/shared/ui/IconButton.jsx similarity index 64% rename from src/components/base/IconButton.jsx rename to src/shared/ui/IconButton.jsx index b0dc05b..a2647fd 100644 --- a/src/components/base/IconButton.jsx +++ b/src/shared/ui/IconButton.jsx @@ -8,11 +8,11 @@ export function IconButton({ children, className = "", label, notice, tone, ...p className={["icon-button", notice ? "icon-button--notice" : "", className].filter(Boolean).join(" ")} aria-label={label} sx={{ - width: className.includes("action-button") ? 32 : 38, - height: className.includes("action-button") ? 32 : 38, + width: "var(--control-height)", + height: "var(--control-height)", padding: 0, border: "1px solid var(--border)", - backgroundColor: className.includes("action-button") ? "var(--bg-card-strong)" : "var(--bg-card)", + backgroundColor: "var(--bg-card)", ...getToneSx(tone), ...sx }} @@ -28,14 +28,14 @@ function getToneSx(tone) { if (tone === "success") { return { color: "var(--success)", - "&:hover": { borderColor: "rgba(46, 219, 95, 0.72)", backgroundColor: "rgba(46, 219, 95, 0.1)" } + "&:hover": { borderColor: "var(--success-border-strong)", backgroundColor: "var(--success-surface)" } }; } if (tone === "danger") { return { color: "var(--danger)", - "&:hover": { borderColor: "rgba(255, 77, 79, 0.72)", backgroundColor: "rgba(255, 77, 79, 0.1)" } + "&:hover": { borderColor: "var(--danger-border-strong)", backgroundColor: "var(--danger-surface)" } }; } diff --git a/src/components/base/KpiCard.jsx b/src/shared/ui/KpiCard.jsx similarity index 100% rename from src/components/base/KpiCard.jsx rename to src/shared/ui/KpiCard.jsx diff --git a/src/components/layout/PageHead.jsx b/src/shared/ui/PageHead.jsx similarity index 100% rename from src/components/layout/PageHead.jsx rename to src/shared/ui/PageHead.jsx diff --git a/src/components/layout/PageSkeleton.jsx b/src/shared/ui/PageSkeleton.jsx similarity index 100% rename from src/components/layout/PageSkeleton.jsx rename to src/shared/ui/PageSkeleton.jsx diff --git a/src/components/base/PaginationBar.jsx b/src/shared/ui/PaginationBar.jsx similarity index 100% rename from src/components/base/PaginationBar.jsx rename to src/shared/ui/PaginationBar.jsx diff --git a/src/shared/ui/RegionSelect.jsx b/src/shared/ui/RegionSelect.jsx new file mode 100644 index 0000000..675f2e8 --- /dev/null +++ b/src/shared/ui/RegionSelect.jsx @@ -0,0 +1,54 @@ +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { restorePlaceholderAfterAllSelected } from "@/shared/ui/selectAllBehavior.js"; + +export function RegionSelect({ + className, + disabled = false, + emptyLabel, + label = "区域", + loading = false, + onChange, + options = [], + required = false, + size = "small", + value +}) { + const isEmpty = !loading && options.length === 0; + const selectedValue = value === undefined || value === null ? "" : String(value); + const hasSelectedOption = !selectedValue || options.some((option) => option.value === selectedValue); + + const handleChange = (event) => { + const nextValue = event.target.value; + const selectedOption = nextValue === "" ? { label: emptyLabel } : options.find((option) => option.value === nextValue); + onChange(nextValue); + restorePlaceholderAfterAllSelected(selectedOption?.label); + }; + const rootClassName = ["region-select", className].filter(Boolean).join(" "); + + return ( + + {emptyLabel ? {emptyLabel} : null} + {!hasSelectedOption ? {selectedValue} : null} + {isEmpty ? ( + + 暂无区域 + + ) : null} + {options.map((option) => ( + + {option.label} + + ))} + + ); +} diff --git a/src/components/base/SearchBox.jsx b/src/shared/ui/SearchBox.jsx similarity index 85% rename from src/components/base/SearchBox.jsx rename to src/shared/ui/SearchBox.jsx index 9d4ceea..5667c76 100644 --- a/src/components/base/SearchBox.jsx +++ b/src/shared/ui/SearchBox.jsx @@ -11,7 +11,7 @@ export function SearchBox({ autoFocus = false, className = "", onChange, placeho onChange={(event) => onChange(event.currentTarget.value)} placeholder={placeholder} type="search" - sx={{ color: "var(--text-primary)", flex: 1, fontSize: 13 }} + sx={{ color: "var(--text-primary)", flex: 1, fontSize: "var(--admin-font-size)" }} /> ); diff --git a/src/components/base/SegmentControl.jsx b/src/shared/ui/SegmentControl.jsx similarity index 79% rename from src/components/base/SegmentControl.jsx rename to src/shared/ui/SegmentControl.jsx index 13d7744..fcdedad 100644 --- a/src/components/base/SegmentControl.jsx +++ b/src/shared/ui/SegmentControl.jsx @@ -37,17 +37,17 @@ export function SegmentControl({ value = "avg" }) { const segmentSx = { minWidth: 52, - height: 24, - padding: "0 10px", - borderRadius: "6px !important", + height: "var(--status-height)", + padding: "0 var(--space-3)", + borderRadius: "var(--radius-control) !important", color: "var(--text-tertiary)", - fontSize: 12, - lineHeight: 1, + fontSize: "var(--admin-font-size)", + lineHeight: "var(--admin-line-height)", "&.Mui-selected": { - backgroundColor: "rgba(37, 99, 235, 0.1)", + backgroundColor: "var(--primary-surface)", color: "var(--text-primary)" }, "&.Mui-selected:hover": { - backgroundColor: "rgba(37, 99, 235, 0.14)" + backgroundColor: "var(--primary-surface-strong)" } }; diff --git a/src/shared/ui/SideDrawer.jsx b/src/shared/ui/SideDrawer.jsx new file mode 100644 index 0000000..eaa7aa2 --- /dev/null +++ b/src/shared/ui/SideDrawer.jsx @@ -0,0 +1,41 @@ +import CloseOutlined from "@mui/icons-material/CloseOutlined"; +import Drawer from "@mui/material/Drawer"; +import { IconButton } from "@/shared/ui/IconButton.jsx"; + +export function SideDrawer({ + actions, + as: Component = "section", + children, + className = "", + contentClassName = "", + onClose, + open, + title, + width = "default", + ...props +}) { + const drawerClassName = [ + "side-drawer", + width === "wide" ? "side-drawer--wide" : "", + className + ].filter(Boolean).join(" "); + + return ( + + +
+ {title ?

{title}

: } + {onClose ? ( + + + + ) : null} +
+
+ {children} +
+ {actions ?
{actions}
: null} +
+
+ ); +} diff --git a/src/shared/ui/StatusSelect.jsx b/src/shared/ui/StatusSelect.jsx new file mode 100644 index 0000000..679713b --- /dev/null +++ b/src/shared/ui/StatusSelect.jsx @@ -0,0 +1,29 @@ +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { restorePlaceholderAfterAllSelected } from "@/shared/ui/selectAllBehavior.js"; + +export function StatusSelect({ className, label = "状态", options = [], size = "small", value, onChange }) { + const handleChange = (event) => { + const nextValue = event.target.value; + const selectedOption = options.find(([optionValue]) => String(optionValue) === String(nextValue)); + onChange(nextValue); + restorePlaceholderAfterAllSelected(selectedOption?.[1]); + }; + + return ( + + {options.map(([optionValue, optionLabel]) => ( + + {optionLabel} + + ))} + + ); +} diff --git a/src/components/base/TimeRangeSelect.jsx b/src/shared/ui/TimeRangeSelect.jsx similarity index 88% rename from src/components/base/TimeRangeSelect.jsx rename to src/shared/ui/TimeRangeSelect.jsx index b297cb5..d13a0e2 100644 --- a/src/components/base/TimeRangeSelect.jsx +++ b/src/shared/ui/TimeRangeSelect.jsx @@ -10,7 +10,7 @@ export function TimeRangeSelect({ className = "", onChange, value }) { value={value} onChange={(event) => onChange(event.target.value)} size="small" - sx={{ width: 116, height: 38, fontSize: 13 }} + sx={{ width: 116, height: 36, fontSize: "var(--admin-font-size)" }} variant="outlined" > {ranges.map((range) => ( diff --git a/src/components/base/ToastProvider.jsx b/src/shared/ui/ToastProvider.jsx similarity index 100% rename from src/components/base/ToastProvider.jsx rename to src/shared/ui/ToastProvider.jsx diff --git a/src/shared/ui/UploadField.jsx b/src/shared/ui/UploadField.jsx new file mode 100644 index 0000000..e653d95 --- /dev/null +++ b/src/shared/ui/UploadField.jsx @@ -0,0 +1,369 @@ +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; +import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined"; +import ImageOutlined from "@mui/icons-material/ImageOutlined"; +import InsertDriveFileOutlined from "@mui/icons-material/InsertDriveFileOutlined"; +import CircularProgress from "@mui/material/CircularProgress"; +import Tooltip from "@mui/material/Tooltip"; +import { useEffect, useId, useRef, useState } from "react"; +import { uploadFile, uploadImage } from "@/shared/api/upload"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import styles from "./UploadField.module.css"; + +const imageAccept = "image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp,.svga,.pag"; +const pagWasmURL = "/vendor/libpag.wasm"; +let pagModulePromise; + +export function UploadField({ + accept, + disabled = false, + kind = "image", + label, + onChange, + value = "" +}) { + const inputId = useId(); + const inputRef = useRef(null); + const { showToast } = useToast(); + const [uploading, setUploading] = useState(false); + const [localPreview, setLocalPreview] = useState(null); + const isImage = kind === "image"; + const source = localPreview?.url || value; + const assetKind = localPreview?.assetKind || getAssetKind(value, kind); + const displayName = localPreview?.name || getDisplayValue(value); + const inputAccept = accept || (isImage ? imageAccept : undefined); + + useEffect(() => { + return () => { + if (localPreview?.url) { + URL.revokeObjectURL(localPreview.url); + } + }; + }, [localPreview]); + + const openPicker = () => { + if (!disabled && !uploading) { + inputRef.current?.click(); + } + }; + + const handleFileChange = async (event) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) { + return; + } + + setLocalPreview((previous) => { + if (previous?.url) { + URL.revokeObjectURL(previous.url); + } + return { + assetKind: getAssetKind(file.name, kind, file.type), + name: file.name, + url: URL.createObjectURL(file) + }; + }); + setUploading(true); + try { + const result = isImage ? await uploadImage(file) : await uploadFile(file); + onChange(result.url); + setLocalPreview(null); + showToast("上传成功", "success"); + } catch (err) { + setLocalPreview(null); + showToast(err.message || "上传失败", "error"); + } finally { + setUploading(false); + } + }; + + const clearValue = () => { + setLocalPreview(null); + onChange(""); + }; + + return ( +
+
+ {label} + {assetKindLabel(assetKind, isImage)} +
+
+ +
+ {displayName || "未上传"} + + + + + + + {source ? ( + + + + + + ) : null} + +
+
+ +
+ ); +} + +function AssetPreview({ assetKind, isImage, src }) { + if (!src) { + return ( + + {isImage ? : } + + ); + } + if (assetKind === "svga") { + return ; + } + if (assetKind === "pag") { + return ; + } + if (isImage) { + return ; + } + return ( + + + + ); +} + +function RasterAssetPreview({ src }) { + const [failed, setFailed] = useState(false); + + useEffect(() => { + setFailed(false); + }, [src]); + + if (failed) { + return ( + + + + ); + } + + return setFailed(true)} />; +} + +function SVGAAssetPreview({ src }) { + const containerRef = useRef(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + const container = containerRef.current; + let player; + setFailed(false); + + async function load() { + try { + const module = await import("svgaplayerweb"); + const SVGA = module.default || module; + if (cancelled || !container) { + return; + } + container.innerHTML = ""; + player = new SVGA.Player(container); + player.loops = 0; + player.clearsAfterStop = false; + player.setContentMode("AspectFit"); + const parser = new SVGA.Parser(container); + parser.load( + src, + (videoItem) => { + if (cancelled) { + return; + } + player.setVideoItem(videoItem); + player.startAnimation(); + }, + () => { + if (!cancelled) { + setFailed(true); + } + } + ); + } catch { + if (!cancelled) { + setFailed(true); + } + } + } + + load(); + + return () => { + cancelled = true; + if (player) { + try { + player.stopAnimation(true); + player.clear(); + } catch { + // Ignore player cleanup errors from partially loaded assets. + } + } + if (container) { + container.innerHTML = ""; + } + }; + }, [src]); + + return ( + + + {failed ? SVGA 预览失败 : null} + + ); +} + +function PAGAssetPreview({ src }) { + const canvasRef = useRef(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + let pagFile; + let pagView; + setFailed(false); + + async function load() { + try { + const PAG = await getPAGModule(); + const buffer = await fetch(src).then((response) => response.arrayBuffer()); + pagFile = await PAG.PAGFile.load(buffer); + if (cancelled || !canvasRef.current) { + return; + } + canvasRef.current.width = pagFile.width(); + canvasRef.current.height = pagFile.height(); + pagView = await PAG.PAGView.init(pagFile, canvasRef.current, { useScale: true }); + if (!pagView) { + throw new Error("PAGView init failed"); + } + pagView.setRepeatCount(0); + await pagView.play(); + } catch { + if (!cancelled) { + setFailed(true); + } + } + } + + load(); + + return () => { + cancelled = true; + if (pagView) { + pagView.destroy(); + } + if (pagFile?.destroy) { + pagFile.destroy(); + } + }; + }, [src]); + + return ( + + + {failed ? PAG 预览失败 : null} + + ); +} + +async function getPAGModule() { + if (!pagModulePromise) { + pagModulePromise = import("libpag").then(({ PAGInit }) => PAGInit({ locateFile: () => pagWasmURL })); + } + return pagModulePromise; +} + +function getDisplayValue(value) { + if (!value) { + return ""; + } + const raw = String(value); + try { + const url = new URL(raw); + const name = url.pathname.split("/").filter(Boolean).pop(); + return name ? decodeURIComponent(name) : raw; + } catch { + return raw; + } +} + +function getAssetKind(value, kind, contentType = "") { + const extension = getExtension(value); + if (extension === "svga") { + return "svga"; + } + if (extension === "pag") { + return "pag"; + } + const normalizedType = String(contentType || "").toLowerCase(); + if (normalizedType.includes("svga")) { + return "svga"; + } + if (normalizedType.includes("pag")) { + return "pag"; + } + return kind === "image" ? "image" : "file"; +} + +function getExtension(value) { + if (!value) { + return ""; + } + const raw = String(value); + const pathname = safePathname(raw); + const name = pathname.split("/").filter(Boolean).pop() || raw; + const cleanName = name.split("?")[0].split("#")[0]; + const dotIndex = cleanName.lastIndexOf("."); + return dotIndex >= 0 ? cleanName.slice(dotIndex + 1).toLowerCase() : ""; +} + +function safePathname(value) { + try { + return new URL(value).pathname; + } catch { + return value; + } +} + +function assetKindLabel(assetKind, isImage) { + if (assetKind === "svga") { + return "SVGA"; + } + if (assetKind === "pag") { + return "PAG"; + } + return isImage ? "图片" : "文件"; +} diff --git a/src/shared/ui/UploadField.module.css b/src/shared/ui/UploadField.module.css new file mode 100644 index 0000000..c8a4511 --- /dev/null +++ b/src/shared/ui/UploadField.module.css @@ -0,0 +1,176 @@ +.root { + display: grid; + width: 100%; + gap: var(--space-2); +} + +.disabled { + opacity: 0.72; +} + +.input { + display: none; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.label { + color: var(--text-secondary); + font-weight: 650; +} + +.type { + color: var(--text-tertiary); + font-size: var(--admin-font-size); +} + +.panel { + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-input); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); +} + +.preview { + position: relative; + display: flex; + width: 100%; + height: 168px; + align-items: center; + justify-content: center; + overflow: hidden; + border: 0; + border-bottom: 1px solid var(--border); + background: + linear-gradient(45deg, rgba(100, 116, 139, 0.08) 25%, transparent 25%) 0 0 / 16px 16px, + linear-gradient(45deg, transparent 75%, rgba(100, 116, 139, 0.08) 75%) 0 0 / 16px 16px, + linear-gradient(45deg, transparent 75%, rgba(100, 116, 139, 0.08) 75%) 8px 8px / 16px 16px, + linear-gradient(45deg, rgba(100, 116, 139, 0.08) 25%, transparent 25%) 8px 8px / 16px 16px, + var(--bg-card-strong); + color: var(--text-tertiary); + cursor: pointer; +} + +.preview:disabled { + cursor: not-allowed; +} + +.image { + width: 100%; + height: 100%; + object-fit: contain; +} + +.empty { + display: inline-flex; + min-width: 0; + align-items: center; + justify-content: center; + gap: var(--space-2); + color: var(--text-tertiary); + font-weight: 650; +} + +.empty svg { + width: 28px; + height: 28px; +} + +.overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.68); + color: var(--primary); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +.player { + position: relative; + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; +} + +.playerSurface { + width: 100%; + height: 100%; +} + +.playerSurface canvas, +.pagCanvas { + width: 100% !important; + height: 100% !important; +} + +.pagCanvas { + display: block; +} + +.footer { + display: flex; + min-height: 52px; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); +} + +.name { + min-width: 0; + overflow: hidden; + color: var(--text-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.actions { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: var(--space-2); +} + +.action { + display: inline-flex; + height: var(--control-height); + min-width: var(--control-height); + align-items: center; + justify-content: center; + gap: var(--space-1); + padding: 0 var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-input-strong); + color: var(--text-secondary); + cursor: pointer; + font-size: var(--admin-font-size); + line-height: var(--admin-line-height); + transition: + border-color var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard), + background var(--motion-fast) var(--ease-standard); +} + +.action:hover:not(:disabled) { + border-color: var(--primary-border); + background: var(--primary-hover); + color: var(--primary); +} + +.action:disabled { + cursor: not-allowed; + opacity: 0.48; +} diff --git a/src/shared/ui/selectAllBehavior.js b/src/shared/ui/selectAllBehavior.js new file mode 100644 index 0000000..11b04dc --- /dev/null +++ b/src/shared/ui/selectAllBehavior.js @@ -0,0 +1,16 @@ +export function restorePlaceholderAfterAllSelected(label) { + if (!isAllLabel(label)) { + return; + } + + globalThis.requestAnimationFrame?.(() => { + const activeElement = globalThis.document?.activeElement; + if (activeElement && typeof activeElement.blur === "function") { + activeElement.blur(); + } + }); +} + +export function isAllLabel(label) { + return String(label || "").trim().startsWith("全部"); +} diff --git a/src/shared/utils/cssTokens.js b/src/shared/utils/cssTokens.js new file mode 100644 index 0000000..98dd5f3 --- /dev/null +++ b/src/shared/utils/cssTokens.js @@ -0,0 +1,7 @@ +export function readCssToken(name, fallback = "") { + if (typeof window === "undefined") { + return fallback; + } + + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; +} diff --git a/src/shared/utils/time.js b/src/shared/utils/time.js new file mode 100644 index 0000000..3ea9cd3 --- /dev/null +++ b/src/shared/utils/time.js @@ -0,0 +1,22 @@ +export function formatTime(date = new Date()) { + return date.toLocaleTimeString("zh-CN", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); +} + +export function formatDateTime(value) { + if (!value) { + return "-"; + } + return new Date(value).toLocaleString("zh-CN", { hour12: false }); +} + +export function formatMillis(value) { + if (!value) { + return "-"; + } + return formatDateTime(Number(value)); +} diff --git a/src/styles/app.css b/src/styles/app.css index dabc836..95619b1 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -1,2333 +1,32 @@ -.app-shell { - display: grid; - grid-template-rows: var(--header-height) 1fr; - min-height: 100vh; - background: var(--bg-page); -} - -@keyframes page-enter { - from { - opacity: 0; - transform: translateY(10px) scale(0.995); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@keyframes panel-enter { - from { - opacity: 0; - transform: translateY(12px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes row-enter { - from { - opacity: 0; - transform: translateX(-8px); - } - to { - opacity: 1; - transform: translateX(0); - } -} - -@keyframes shimmer { - from { - background-position: 120% 0; - } - to { - background-position: -120% 0; - } -} - -@keyframes notice-pop { - 0%, - 100% { - transform: scale(1); - } - 45% { - transform: scale(1.18); - } -} - -@keyframes progress-fill { - from { - transform: scaleX(0); - } - to { - transform: scaleX(1); - } -} - -.header { - display: grid; - grid-template-columns: 248px 1fr; - height: var(--header-height); - border-bottom: 1px solid var(--border); - background: var(--bg-header); - transition: grid-template-columns var(--motion-slow) var(--ease-emphasized); -} - -.brand { - display: flex; - align-items: center; - gap: 14px; - height: var(--header-height); - padding: 0 22px; - border-right: 1px solid var(--border); - background: var(--bg-sidebar); -} - -.brand-mark { - display: grid; - width: 38px; - height: 38px; - place-items: center; - border-radius: 8px; - background: linear-gradient(135deg, var(--primary), var(--brand-secondary)); - color: var(--active-contrast); - transition: - transform var(--motion-base) var(--ease-emphasized), - box-shadow var(--motion-base) var(--ease-standard); -} - -.brand:hover .brand-mark { - box-shadow: 0 10px 24px rgba(37, 99, 235, 0.22); - transform: rotate(-6deg) scale(1.05); -} - -.brand-title { - display: block; - color: var(--text-primary); - font-size: 16px; - font-weight: 650; -} - -.brand-meta { - display: block; - margin-top: 2px; - color: var(--text-tertiary); - font-size: 12px; -} - -.header-tools { - display: flex; - min-width: 0; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 0 24px; -} - -.header-left-tools, -.header-right-tools { - display: flex; - min-width: 0; - align-items: center; - gap: 12px; -} - -.header-right-tools { - margin-left: auto; - justify-content: flex-end; -} - -.header-location { - display: inline-flex; - min-width: 74px; - height: 36px; - align-items: center; - justify-content: center; - padding: 0 16px; - border: 1px solid rgba(37, 99, 235, 0.24); - border-radius: 8px; - background: rgba(37, 99, 235, 0.08); - box-shadow: - var(--shadow-soft), - inset 0 -1px 0 rgba(37, 99, 235, 0.22); - color: var(--text-primary); - font-size: 15px; - font-weight: 650; - white-space: nowrap; -} - -.search-box { - display: flex; - flex: 1 1 420px; - min-width: 180px; - max-width: 520px; - height: 38px; - align-items: center; - gap: 10px; - padding: 0 12px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--bg-input); - color: var(--text-tertiary); - transition: - border-color var(--motion-base) var(--ease-standard), - background var(--motion-base) var(--ease-standard), - box-shadow var(--motion-base) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.search-box:focus-within { - border-color: rgba(37, 99, 235, 0.78); - background: var(--bg-input-strong); - box-shadow: 0 0 0 3px var(--focus-ring); - transform: translateY(-1px); -} - -.search-box svg { - transition: - color var(--motion-base) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.search-box:focus-within svg { - color: var(--primary); - transform: scale(1.08); -} - -.search-trigger { - display: flex; - width: min(360px, 34vw); - min-width: 260px; - height: 38px; - align-items: center; - gap: 10px; - padding: 0 12px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--bg-input); - color: var(--text-tertiary); - cursor: pointer; - text-align: left; - transition: - border-color var(--motion-base) var(--ease-standard), - background var(--motion-base) var(--ease-standard), - box-shadow var(--motion-base) var(--ease-standard), - color var(--motion-base) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.search-trigger span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.search-trigger:hover, -.search-trigger:focus-visible { - border-color: rgba(37, 99, 235, 0.78); - background: var(--bg-input-strong); - box-shadow: 0 0 0 3px var(--focus-ring); - color: var(--text-primary); - transform: translateY(-1px); -} - -.search-box input { - width: 100%; - min-width: 0; - border: 0; - background: transparent; - color: var(--text-primary); - outline: 0; -} - -.search-box input::placeholder { - color: var(--text-tertiary); -} - -.time-select { - width: 116px; - height: 38px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--bg-card); - color: var(--text-secondary); -} - -.icon-button { - display: inline-grid; - width: 38px; - height: 38px; - place-items: center; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--bg-card); - color: var(--text-secondary); - cursor: pointer; - transition: - background var(--motion-fast) var(--ease-standard), - color var(--motion-fast) var(--ease-standard), - border-color var(--motion-fast) var(--ease-standard), - box-shadow var(--motion-fast) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.icon-button:hover { - border-color: rgba(37, 99, 235, 0.7); - background: var(--bg-hover); - box-shadow: var(--shadow-soft); - color: var(--text-primary); - transform: translateY(-1px); -} - -.icon-button:active { - box-shadow: none; - transform: translateY(0) scale(0.96); -} - -.icon-button--notice { - position: relative; -} - -.icon-button--notice span { - position: absolute; - top: -5px; - right: -5px; - display: grid; - min-width: 17px; - height: 17px; - place-items: center; - border-radius: 999px; - background: var(--danger); - color: var(--active-contrast); - font-size: 10px; - font-weight: 700; - animation: notice-pop 1500ms var(--ease-emphasized) infinite; -} - -.user-pill { - display: flex; - height: 38px; - align-items: center; - gap: 8px; - padding: 0 12px; - border: 1px solid var(--border); - border-radius: 8px; - background: transparent; - color: var(--text-secondary); - cursor: pointer; - font-size: 13px; - transition: - border-color var(--motion-base) var(--ease-standard), - background var(--motion-base) var(--ease-standard), - color var(--motion-base) var(--ease-standard); -} - -.user-pill:hover { - border-color: rgba(37, 99, 235, 0.52); - background: rgba(37, 99, 235, 0.08); - color: var(--text-primary); -} - -.user-pill:active { - transform: scale(0.98); -} - -.user-menu .MuiPaper-root { - min-width: 132px; - margin-top: 8px; -} - -.user-menu__item { - display: flex; - gap: 8px; - min-height: 38px; - color: var(--text-secondary); - font-size: 13px; -} - -.user-menu__item:hover { - color: var(--text-primary); -} - -.body-grid { - display: grid; - grid-template-columns: 248px 1fr; - min-height: 0; - transition: grid-template-columns var(--motion-slow) var(--ease-emphasized); -} - -.sidebar { - position: relative; - z-index: 25; - min-height: 0; - overflow: visible; - padding: 16px 12px; - border-right: 1px solid var(--border); - background: var(--bg-sidebar); -} - -.app-shell--sidebar-collapsed .header, -.app-shell--sidebar-collapsed .body-grid { - grid-template-columns: 84px 1fr; -} - -.app-shell--sidebar-collapsed .brand { - gap: 0; - justify-content: center; - padding: 0; -} - -.brand-text, -.nav-label { - display: inline-grid; - min-width: 0; - max-width: 160px; - opacity: 1; - overflow: hidden; - transform: translateX(0); - transition: - max-width var(--motion-slow) var(--ease-emphasized), - opacity var(--motion-base) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.app-shell--sidebar-collapsed .brand-text, -.sidebar--collapsed .nav-label { - max-width: 0; - opacity: 0; - pointer-events: none; - transform: translateX(-6px); -} - -.sidebar--collapsed { - padding: 16px 10px; -} - -.sidebar--collapsed .nav-item { - width: 44px; - justify-content: center; - justify-self: center; - padding: 0; -} - -.nav-list { - display: grid; - gap: 6px; -} - -.nav-group { - display: grid; - position: relative; - gap: 4px; -} - -.nav-item { - position: relative; - display: flex; - width: 100%; - height: 44px; - align-items: center; - gap: 10px; - padding: 0 12px; - border-radius: 8px; - background: transparent; - color: var(--text-secondary); - cursor: pointer; - overflow: hidden; - transition: - background var(--motion-base) var(--ease-standard), - color var(--motion-base) var(--ease-standard), - gap var(--motion-slow) var(--ease-emphasized), - padding var(--motion-slow) var(--ease-emphasized), - width var(--motion-slow) var(--ease-emphasized), - transform var(--motion-base) var(--ease-emphasized); -} - -.nav-item--parent { - padding-right: 10px; -} - -.nav-item--open:not(.nav-item--active) { - background: rgba(37, 99, 235, 0.06); - color: var(--text-primary); -} - -.nav-expand { - margin-left: auto; - color: var(--text-tertiary); - transition: - color var(--motion-base) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.nav-item--open .nav-expand, -.nav-item:hover .nav-expand { - color: currentColor; -} - -.nav-item--expanded .nav-expand { - transform: rotate(180deg); -} - -.nav-children { - display: grid; - grid-template-rows: 0fr; - opacity: 0; - overflow: hidden; - padding-left: 20px; - pointer-events: none; - transform: translateY(-4px); - transition: - grid-template-rows var(--motion-slow) var(--ease-emphasized), - opacity var(--motion-base) var(--ease-standard), - transform var(--motion-slow) var(--ease-emphasized); -} - -.nav-children--open { - grid-template-rows: 1fr; - opacity: 1; - pointer-events: auto; - transform: translateY(0); -} - -.nav-children__inner { - display: grid; - gap: 4px; - min-height: 0; - overflow: hidden; -} - -.nav-item--child { - height: 38px; - padding-left: 12px; - font-size: 13px; -} - -.nav-item--child svg { - font-size: 18px; -} - -.sidebar--collapsed .nav-children, -.sidebar--collapsed .nav-item--parent .nav-expand { - display: none !important; -} - -.sidebar--collapsed .nav-item--open, -.sidebar--collapsed .nav-item--open:hover, -.sidebar--collapsed .nav-item--flyout-open, -.sidebar--collapsed .nav-item--flyout-open:hover { - background: linear-gradient(90deg, rgba(37, 99, 235, 0.96), rgba(14, 165, 233, 0.34)); - box-shadow: inset 0 0 0 1px rgba(14, 165, 233, 0.2); - color: var(--active-contrast); -} - -.nav-flyout { - position: absolute; - top: 0; - left: calc(100% + 12px); - z-index: 30; - display: grid; - width: 168px; - gap: 4px; - padding: 8px; - border: 1px solid rgba(37, 99, 235, 0.22); - border-radius: 8px; - background: var(--bg-flyout); - box-shadow: var(--shadow-panel); - animation: panel-enter var(--motion-base) var(--ease-emphasized) both; -} - -.nav-flyout::before { - position: absolute; - top: 16px; - left: -6px; - width: 10px; - height: 10px; - border-bottom: 1px solid rgba(37, 99, 235, 0.22); - border-left: 1px solid rgba(37, 99, 235, 0.22); - background: var(--bg-flyout); - content: ""; - transform: rotate(45deg); -} - -.nav-flyout__title { - padding: 4px 8px 6px; - color: var(--text-tertiary); - font-size: 12px; - font-weight: 650; -} - -.nav-flyout__item { - display: flex; - height: 38px; - align-items: center; - gap: 10px; - padding: 0 10px; - border-radius: 8px; - background: transparent; - color: var(--text-secondary); - cursor: pointer; - font-size: 13px; - text-align: left; - transition: - background var(--motion-fast) var(--ease-standard), - color var(--motion-fast) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.nav-flyout__item:hover, -.nav-flyout__item--active { - background: rgba(37, 99, 235, 0.1); - color: var(--text-primary); - transform: translateX(2px); -} - -.nav-item::before { - position: absolute; - inset: 8px auto 8px 0; - width: 3px; - border-radius: 0 999px 999px 0; - background: var(--primary); - content: ""; - opacity: 0; - transform: translateX(-4px); - transition: - opacity var(--motion-base) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.nav-item:hover { - background: var(--bg-hover); - color: var(--text-primary); - transform: translateX(2px); -} - -.nav-item--active, -.nav-item--active:hover { - background: linear-gradient(90deg, rgba(37, 99, 235, 0.96), rgba(14, 165, 233, 0.34)); - box-shadow: inset 0 0 0 1px rgba(14, 165, 233, 0.2); - color: var(--active-contrast); -} - -.nav-item:hover::before, -.nav-item--active::before { - opacity: 1; - transform: translateX(0); -} - -.nav-label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.workspace { - position: relative; - min-width: 0; - min-height: 0; - overflow: auto; - padding: 24px; -} - -.page-transition { - animation: page-enter var(--motion-slow) var(--ease-emphasized) both; -} - -.page-skeleton { - display: grid; - height: calc(100vh - var(--header-height) - 48px); - height: calc(100dvh - var(--header-height) - 48px); - grid-template-rows: 38px minmax(96px, 0.7fr) minmax(160px, 1fr) minmax(200px, 1.2fr); - gap: 16px; - animation: page-enter var(--motion-slow) var(--ease-emphasized) both; -} - -.page-skeleton__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.page-skeleton__title { - width: min(220px, 36%); - height: 30px; -} - -.page-skeleton__actions { - display: flex; - gap: 10px; -} - -.page-skeleton__actions .page-skeleton__block { - width: 96px; - height: 36px; -} - -.page-skeleton__kpis { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 16px; -} - -.page-skeleton__charts { - display: grid; - grid-template-columns: minmax(280px, 1.15fr) repeat(2, minmax(260px, 1fr)) minmax(260px, 0.95fr); - gap: 16px; - min-height: 0; -} - -.page-skeleton__content { - display: grid; - grid-template-columns: 280px minmax(0, 1fr); - gap: 16px; - min-height: 0; -} - -.page-skeleton__block { - position: relative; - width: 100%; - height: 100%; - overflow: hidden; - border: 1px solid var(--border); - border-radius: 10px; - background-color: var(--skeleton-surface); - opacity: 1; - transform: translateZ(0); - animation: panel-enter var(--motion-slow) var(--ease-emphasized) both; -} - -.page-skeleton__block::after { - background: linear-gradient(90deg, transparent, var(--skeleton-shine), transparent); -} - -.page-skeleton__kpi:nth-child(2), -.page-skeleton__charts .page-skeleton__block:nth-child(2), -.page-skeleton__content .page-skeleton__block:nth-child(2) { - animation-delay: 40ms; -} - -.page-skeleton__kpi:nth-child(3), -.page-skeleton__charts .page-skeleton__block:nth-child(3) { - animation-delay: 80ms; -} - -.page-skeleton__kpi:nth-child(4), -.page-skeleton__charts .page-skeleton__block:nth-child(4) { - animation-delay: 120ms; -} - -.page-skeleton__kpi:nth-child(5) { - animation-delay: 160ms; -} - -.search-dialog { - overflow: hidden; - border: 1px solid var(--border); - border-radius: 12px; - background: var(--bg-card); - background-image: none; - box-shadow: var(--shadow-dialog); - animation: panel-enter var(--motion-slow) var(--ease-emphasized) both; -} - -.search-dialog__head { - padding: 18px; - border-bottom: 1px solid var(--border); - background: var(--bg-card-strong); -} - -.search-dialog__input { - max-width: none; - width: 100%; - height: 44px; -} - -.search-dialog__list { - display: grid; - gap: 6px; - max-height: min(56vh, 520px); - overflow: auto; - padding: 10px; -} - -.search-dialog__item { - min-height: 64px; - border: 1px solid transparent; - border-radius: 10px; - color: var(--text-primary); - transition: - background var(--motion-base) var(--ease-standard), - border-color var(--motion-base) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.search-dialog__item:hover { - border-color: rgba(37, 99, 235, 0.34); - background: rgba(37, 99, 235, 0.08); - transform: translateX(3px); -} - -.search-dialog__primary { - color: var(--text-primary); - font-size: 14px; - font-weight: 650; -} - -.search-dialog__secondary { - color: var(--text-tertiary); - font-size: 12px; -} - -.search-dialog__empty { - display: grid; - min-height: 120px; - place-items: center; - color: var(--text-tertiary); - font-size: 13px; -} - -.page-head { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 16px; - margin-bottom: 18px; -} - -.page-title { - margin: 0; - font-size: 20px; - font-weight: 700; -} - -.page-meta { - margin: 6px 0 0; - color: var(--text-tertiary); - font-size: 12px; -} - -.page-actions { - display: flex; - align-items: center; - gap: 10px; -} - -.button { - display: inline-flex; - height: 36px; - align-items: center; - justify-content: center; - gap: 8px; - padding: 0 12px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--bg-card); - color: var(--text-secondary); - cursor: pointer; - font-size: 13px; - white-space: nowrap; - transition: - background var(--motion-fast) var(--ease-standard), - border-color var(--motion-fast) var(--ease-standard), - box-shadow var(--motion-fast) var(--ease-standard), - color var(--motion-fast) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.button:hover { - border-color: rgba(37, 99, 235, 0.7); - background: var(--bg-hover); - box-shadow: var(--shadow-soft); - color: var(--text-primary); - transform: translateY(-1px); -} - -.button:active { - box-shadow: none; - transform: translateY(0) scale(0.985); -} - -.button:disabled, -.action-button:disabled { - cursor: not-allowed; - opacity: 0.42; -} - -.button--primary { - border-color: var(--primary); - background: var(--primary); - color: var(--active-contrast); -} - -.button--success { - color: var(--success); -} - -.button--danger { - color: var(--danger); -} - -.kpi-grid { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 16px; -} - -.card { - border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg-card); -} - -.kpi-card { - min-height: 132px; - padding: 20px; - 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); -} - -.kpi-grid .kpi-card:nth-child(2), -.service-kpi-row .kpi-card:nth-child(2) { - animation-delay: 40ms; -} - -.kpi-grid .kpi-card:nth-child(3), -.service-kpi-row .kpi-card:nth-child(3) { - animation-delay: 80ms; -} - -.kpi-grid .kpi-card:nth-child(4), -.service-kpi-row .kpi-card:nth-child(4) { - animation-delay: 120ms; -} - -.kpi-grid .kpi-card:nth-child(5), -.service-kpi-row .chart-card { - animation-delay: 160ms; -} - -.kpi-card:hover, -.chart-card:hover, -.table-card:hover, -.server-rail:hover, -.inline-inspector:hover { - border-color: rgba(37, 99, 235, 0.34); - box-shadow: var(--shadow-panel); - transform: translateY(-2px); -} - -.kpi-card__top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.kpi-card__label, -.card-title { - color: var(--text-secondary); - font-size: 14px; - font-weight: 650; -} - -.metric-icon { - display: grid; - width: 34px; - height: 34px; - place-items: center; - border-radius: 8px; - background: rgba(37, 99, 235, 0.1); - color: var(--primary); - transition: - background var(--motion-base) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.kpi-card:hover .metric-icon { - transform: scale(1.08) rotate(-4deg); -} - -.metric-icon--success { - background: rgba(22, 163, 74, 0.1); - color: var(--success); -} - -.metric-icon--warning { - background: rgba(217, 119, 6, 0.1); - color: var(--warning); -} - -.metric-icon--info { - background: rgba(2, 132, 199, 0.1); - color: var(--info); -} - -.kpi-value { - margin-top: 18px; - color: var(--text-primary); - font-size: 32px; - font-weight: 760; - line-height: 1; -} - -.kpi-value span { - margin-left: 5px; - font-size: 14px; - font-weight: 600; -} - -.kpi-sub { - margin-top: 12px; - color: var(--text-tertiary); - font-size: 12px; -} - -.status-ok { - color: var(--success); -} - -.status-warn { - color: var(--warning); -} - -.status-danger { - color: var(--danger); -} - -.panel-grid { - display: grid; - grid-template-columns: minmax(0, 1.35fr) minmax(340px, 0.65fr); - gap: 16px; - margin-top: 16px; -} - -.overview-charts { - display: grid; - grid-template-columns: minmax(280px, 1.15fr) repeat(2, minmax(260px, 1fr)) minmax(260px, 0.95fr); - gap: 16px; - margin-top: 16px; -} - -.chart-card { - min-height: 304px; - padding: 20px; - 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: 6px; -} - -.chart-card--compact .segmented { - display: none; -} - -.card-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; - margin-bottom: 18px; -} - -.segmented { - display: inline-flex; - height: 30px; - padding: 2px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--bg-card-strong); -} - -.segment { - min-width: 52px; - padding: 0 10px; - border-radius: 6px; - background: transparent; - color: var(--text-tertiary); - cursor: pointer; - font-size: 12px; - 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: rgba(37, 99, 235, 0.1); - 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: rgba(100, 116, 139, 0.16); - 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: 22px; - 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: 50%; -} - -.donut-core { - display: grid; - width: 78px; - height: 78px; - place-items: center; - border-radius: 50%; - background: var(--bg-card); - color: var(--text-secondary); - font-size: 12px; -} - -.donut-core strong { - display: block; - color: var(--text-primary); - font-size: 24px; - line-height: 1; -} - -.donut-legend { - display: grid; - gap: 14px; -} - -.legend-row { - display: grid; - grid-template-columns: 8px 1fr auto; - align-items: center; - gap: 10px; - color: var(--text-secondary); - font-size: 13px; -} - -.legend-row strong { - color: var(--text-primary); -} - -.legend-dot { - width: 8px; - height: 8px; - border-radius: 50%; -} - -.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: 12px; -} - -.alert-item { - display: grid; - grid-template-columns: 8px 1fr auto; - align-items: center; - gap: 12px; - 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: 50%; -} - -.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: 13px; - font-weight: 620; -} - -.alert-desc, -.alert-time { - color: var(--text-tertiary); - font-size: 12px; -} - -.table-card { - margin-top: 16px; - 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: 16px; - margin-top: 16px; -} - -.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: 8px; -} - -.server-item { - display: grid; - grid-template-columns: 8px 1fr auto; - align-items: center; - gap: 12px; - width: 100%; - min-height: 64px; - padding: 10px 12px; - border-radius: 8px; - 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: 4px; - color: var(--text-tertiary); - font-size: 12px; -} - -.server-status { - width: 8px; - height: 8px; - border-radius: 50%; - 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: 12px; -} - -.alert-strip { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 16px; - margin-top: 16px; - padding-bottom: 8px; -} - -.table-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; - padding: 18px 20px; - border-bottom: 1px solid var(--border); -} - -.filter-row { - display: flex; - align-items: center; - gap: 8px; -} - -.filter-chip { - height: 30px; - padding: 0 10px; - border: 1px solid var(--border); - border-radius: 999px; - background: transparent; - color: var(--text-tertiary); - cursor: pointer; - font-size: 12px; - transition: - background var(--motion-fast) var(--ease-standard), - border-color var(--motion-fast) var(--ease-standard), - box-shadow var(--motion-fast) var(--ease-standard), - color var(--motion-fast) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.filter-chip:hover { - box-shadow: var(--shadow-soft); - transform: translateY(-1px); -} - -.filter-chip--active { - border-color: rgba(37, 99, 235, 0.7); - background: rgba(37, 99, 235, 0.1); - color: var(--text-primary); -} - -.service-kpi-row { - display: grid; - grid-template-columns: repeat(4, minmax(170px, 1fr)); - gap: 16px; -} - -.service-kpi-row .kpi-card { - min-height: 150px; -} - -.filters-panel { - display: flex; - align-items: center; - gap: 14px; - margin-top: 16px; -} - -.filters-panel .search-box { - flex: 0 0 280px; -} - -.service-page-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) 320px; - gap: 16px; - margin-top: 12px; -} - -.service-page-grid .table-card { - margin-top: 0; -} - -.user-kpi-row { - display: grid; - grid-template-columns: repeat(4, minmax(170px, 1fr)); - gap: 16px; -} - -.user-filters-panel { - flex-wrap: wrap; -} - -.user-filters-panel .search-box { - flex: 0 0 320px; -} - -.user-table-card { - margin-top: 16px; -} - -.user-table { - min-width: 1040px; -} - -.user-row { - display: grid; - grid-template-columns: 1.4fr 1fr 1fr 0.72fr 0.72fr 0.9fr 0.72fr; - align-items: center; - min-height: 64px; - padding: 0 20px; - border-bottom: 1px solid var(--row-border); - color: var(--text-secondary); - font-size: 13px; - animation: row-enter var(--motion-slow) var(--ease-emphasized) both; - transition: - background var(--motion-base) var(--ease-standard), - border-color var(--motion-base) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.user-table--selectable { - min-width: 1180px; -} - -.user-table--selectable .user-row { - grid-template-columns: 48px 1.4fr 1fr 1fr 0.72fr 0.72fr 0.9fr 1.2fr; -} - -.user-row--head { - min-height: 44px; - background: var(--bg-card-strong); - color: var(--text-tertiary); - font-size: 12px; - font-weight: 650; -} - -.user-row:not(.user-row--head):hover { - background: rgba(37, 99, 235, 0.06); - border-color: rgba(37, 99, 235, 0.2); - transform: translateX(3px); -} - -.user-row:nth-child(3) { - animation-delay: 35ms; -} - -.user-row:nth-child(4) { - animation-delay: 70ms; -} - -.user-row:nth-child(5) { - animation-delay: 105ms; -} - -.user-row:nth-child(6) { - animation-delay: 140ms; -} - -.user-cell { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.user-main { - display: flex; - min-width: 0; - align-items: center; - gap: 12px; -} - -.user-avatar { - display: grid; - flex: 0 0 auto; - width: 34px; - height: 34px; - place-items: center; - border-radius: 8px; - background: rgba(37, 99, 235, 0.1); - color: var(--primary); - transition: - background var(--motion-base) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.user-row:hover .user-avatar { - background: rgba(37, 99, 235, 0.14); - transform: scale(1.08); -} - -.service-table { - min-width: 980px; -} - -.service-row { - display: grid; - grid-template-columns: 2.1fr 0.85fr 1fr 1fr 0.9fr 1.35fr; - align-items: center; - min-height: 64px; - padding: 0 20px; - border-bottom: 1px solid var(--row-border); - font-size: 13px; - animation: row-enter var(--motion-slow) var(--ease-emphasized) both; - transition: - background var(--motion-base) var(--ease-standard), - border-color var(--motion-base) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.service-row--head { - min-height: 44px; - background: var(--bg-card-strong); - color: var(--text-tertiary); - font-size: 12px; - font-weight: 650; -} - -.service-row:not(.service-row--head) { - cursor: pointer; -} - -.service-row:not(.service-row--head):hover { - background: rgba(37, 99, 235, 0.06); - border-color: rgba(37, 99, 235, 0.2); - transform: translateX(3px); -} - -.service-row:nth-child(3) { - animation-delay: 35ms; -} - -.service-row:nth-child(4) { - animation-delay: 70ms; -} - -.service-row:nth-child(5) { - animation-delay: 105ms; -} - -.service-row:nth-child(6) { - animation-delay: 140ms; -} - -.service-row:nth-child(7) { - animation-delay: 175ms; -} - -.service-row:nth-child(8) { - animation-delay: 210ms; -} - -.service-cell { - min-width: 0; -} - -.service-main { - display: flex; - min-width: 0; - align-items: center; - gap: 12px; -} - -.service-icon { - display: grid; - flex: 0 0 auto; - width: 34px; - height: 34px; - place-items: center; - border-radius: 8px; - background: rgba(2, 132, 199, 0.1); - color: var(--info); - transition: - background var(--motion-base) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.service-row:hover .service-icon { - background: rgba(2, 132, 199, 0.14); - transform: scale(1.08); -} - -.service-name { - overflow: hidden; - color: var(--text-primary); - font-weight: 650; - text-overflow: ellipsis; - white-space: nowrap; -} - -.service-desc { - overflow: hidden; - margin-top: 4px; - color: var(--text-tertiary); - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.status-badge { - display: inline-flex; - height: 24px; - align-items: center; - gap: 7px; - padding: 0 10px; - border-radius: 999px; - background: rgba(100, 116, 139, 0.1); - color: var(--text-secondary); - font-size: 12px; - transition: - background var(--motion-base) var(--ease-standard), - border-color var(--motion-base) var(--ease-standard), - color var(--motion-base) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.status-badge:hover { - transform: translateY(-1px); -} - -.status-point { - width: 8px; - height: 8px; - border-radius: 50%; - transition: - background var(--motion-base) var(--ease-standard), - box-shadow var(--motion-base) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.status-badge:hover .status-point { - transform: scale(1.18); -} - -.status-badge--running .status-point { - background: var(--success); - box-shadow: 0 0 12px rgba(22, 163, 74, 0.28); -} - -.status-badge--running { - color: var(--success); -} - -.status-badge--warning .status-point { - background: var(--warning); - box-shadow: 0 0 12px rgba(217, 119, 6, 0.28); -} - -.status-badge--warning { - color: var(--warning); -} - -.status-badge--error .status-point { - background: var(--danger); - box-shadow: 0 0 12px rgba(220, 38, 38, 0.28); -} - -.status-badge--error { - color: var(--danger); -} - -.status-badge--stopped .status-point { - background: var(--stopped); -} - -.resource-cell { - display: grid; - gap: 7px; - width: min(144px, 92%); -} - -.resource-line { - display: flex; - justify-content: space-between; - color: var(--text-secondary); - font-size: 12px; -} - -.progress { - height: 6px; - overflow: hidden; - border-radius: 999px; - background: rgba(100, 116, 139, 0.16); -} - -.progress span { - display: block; - height: 100%; - border-radius: inherit; - background: var(--primary); - transform-origin: left center; - animation: progress-fill 650ms var(--ease-emphasized) both; - transition: - background var(--motion-base) var(--ease-standard), - width var(--motion-slow) var(--ease-emphasized); -} - -.progress--memory span { - background: var(--info); -} - -.deploy-cell { - color: var(--text-secondary); -} - -.muted { - color: var(--text-tertiary); -} - -.row-actions { - display: flex; - align-items: center; - gap: 8px; -} - -.action-button { - display: inline-grid; - width: 32px; - height: 32px; - place-items: center; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--bg-card-strong); - color: var(--text-secondary); - cursor: pointer; - transition: - background var(--motion-fast) var(--ease-standard), - color var(--motion-fast) var(--ease-standard), - border-color var(--motion-fast) var(--ease-standard), - box-shadow var(--motion-fast) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); -} - -.action-button:hover { - border-color: rgba(37, 99, 235, 0.7); - background: var(--bg-hover); - box-shadow: var(--shadow-soft); - color: var(--text-primary); - transform: translateY(-1px); -} - -.action-button:active { - box-shadow: none; - transform: translateY(0) scale(0.94); -} - -.action-button--success { - color: var(--success); -} - -.action-button--danger { - color: var(--danger); -} - -.drawer-scrim { - position: fixed; - inset: var(--header-height) 0 0; - z-index: 19; - display: block; - background: rgba(15, 23, 42, 0); - opacity: 0; - transition: - background var(--motion-slow) var(--ease-standard), - opacity var(--motion-slow) var(--ease-standard); -} - -.drawer-scrim--open { - background: var(--overlay-scrim); - opacity: 1; -} - -.drawer { - position: fixed; - top: var(--header-height); - right: 0; - bottom: 0; - z-index: 20; - display: grid; - width: 360px; - grid-template-rows: auto auto 1fr auto; - border-left: 1px solid var(--border); - background: var(--bg-card); - box-shadow: var(--shadow-drawer); - transform: translateX(100%); - transition: - opacity 280ms var(--ease-standard), - transform 280ms var(--ease-emphasized); - opacity: 0.86; -} - -.drawer--open { - transform: translateX(0); - opacity: 1; -} - -.drawer-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 18px 18px 14px; - border-bottom: 1px solid var(--border); -} - -.panel-title-line { - display: flex; - min-width: 0; - align-items: center; - gap: 10px; -} - -.drawer-title { - margin: 0; - color: var(--text-primary); - font-size: 16px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.drawer-tabs { - display: grid; - grid-template-columns: repeat(4, 1fr); - border-bottom: 1px solid var(--border); -} - -.drawer-tab { - height: 42px; - background: transparent; - color: var(--text-tertiary); - cursor: pointer; - font-size: 12px; - transition: - background var(--motion-fast) var(--ease-standard), - color var(--motion-fast) var(--ease-standard), - box-shadow var(--motion-base) var(--ease-standard); -} - -.drawer-tab:hover { - background: rgba(37, 99, 235, 0.08); - color: var(--text-primary); -} - -.drawer-tab--active { - color: var(--primary); - box-shadow: inset 0 -2px 0 var(--primary); -} - -.drawer-body { - min-height: 0; - overflow: auto; - padding: 18px; -} - -.detail-grid { - display: grid; - gap: 12px; -} - -.detail-row { - display: flex; - justify-content: space-between; - gap: 16px; - padding-bottom: 12px; - border-bottom: 1px solid var(--detail-border); - color: var(--text-secondary); - font-size: 13px; - animation: row-enter var(--motion-slow) var(--ease-emphasized) both; -} - -.detail-row span:first-child { - color: var(--text-tertiary); -} - -.log-list { - display: grid; - gap: 10px; - margin: 0; - padding: 0; - list-style: none; -} - -.log-list li { - padding-bottom: 10px; - border-bottom: 1px solid var(--detail-border); - color: var(--text-secondary); - font-size: 12px; - line-height: 1.6; - animation: row-enter var(--motion-slow) var(--ease-emphasized) both; -} - -.drawer-actions { - display: flex; - justify-content: flex-end; - gap: 10px; - padding: 14px 18px; - border-top: 1px solid var(--border); -} - -.inline-inspector { - display: grid; - min-height: 420px; - grid-template-rows: auto auto 1fr auto; - 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); -} - -.inline-inspector .drawer-body { - max-height: none; -} - -.empty-state { - padding: 34px 20px; - color: var(--text-tertiary); - text-align: center; -} - -.login-page { - display: grid; - min-height: 100vh; - place-items: center; - padding: 24px; - background: - linear-gradient(180deg, rgba(37, 99, 235, 0.08), rgba(37, 99, 235, 0)), - var(--bg-page); -} - -.login-panel { - display: grid; - width: min(420px, 100%); - gap: 24px; - padding: 28px; - border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg-card); - box-shadow: var(--shadow-panel); - animation: panel-enter var(--motion-slow) var(--ease-emphasized) both; -} - -.login-brand { - display: flex; - align-items: center; - gap: 14px; -} - -.login-brand h1 { - margin: 0; - color: var(--text-primary); - font-size: 20px; -} - -.login-brand p { - margin: 4px 0 0; - color: var(--text-tertiary); - font-size: 13px; -} - -.login-form, -.form-drawer { - display: grid; - gap: 16px; -} - -.form-drawer { - width: min(420px, 100vw); - padding: 22px; -} - -.form-drawer--wide { - width: min(560px, 100vw); -} - -.form-drawer h2 { - margin: 0 0 4px; - color: var(--text-primary); - font-size: 18px; -} - -.form-drawer__actions { - display: flex; - justify-content: flex-end; - gap: 10px; - padding-top: 8px; -} - -.form-section-title { - color: var(--text-secondary); - font-size: 13px; - font-weight: 700; -} - -.permission-section { - display: grid; - gap: 10px; -} - -.permission-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.permission-check { - display: flex; - align-items: center; - gap: 8px; - min-height: 34px; - padding: 0 10px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--bg-card-strong); - color: var(--text-secondary); - font-size: 12px; -} - -.batch-actions { - display: flex; - gap: 8px; -} - -.pagination-bar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-top: 12px; - color: var(--text-tertiary); - font-size: 12px; -} - -.pagination-bar__actions { - display: flex; - align-items: center; - gap: 8px; -} - -.data-state { - display: grid; - min-height: 260px; - place-items: center; - gap: 14px; - border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg-card); - color: var(--text-tertiary); -} - -.data-state--loading { - padding: 16px; -} - -.data-state--loading .page-skeleton__block { - min-height: 220px; -} - -.data-state__title { - color: var(--text-secondary); - font-size: 14px; - font-weight: 650; -} - -.admin-table { - min-width: 900px; -} - -.admin-row { - display: grid; - align-items: center; - min-height: 58px; - padding: 0 20px; - border-bottom: 1px solid var(--row-border); - color: var(--text-secondary); - font-size: 13px; - animation: row-enter var(--motion-slow) var(--ease-emphasized) both; -} - -.admin-row--head { - min-height: 44px; - background: var(--bg-card-strong); - color: var(--text-tertiary); - font-size: 12px; - font-weight: 650; -} - -.admin-table--roles .admin-row { - grid-template-columns: 1fr 1fr 0.7fr 1.6fr 0.8fr; -} - -.admin-table--menus .admin-row { - grid-template-columns: 1.2fr 1.4fr 1.2fr 0.8fr; -} - -.admin-table--login-logs .admin-row { - grid-template-columns: 1fr 1fr 0.7fr 1.6fr 1.2fr; -} - -.admin-table--operation-logs .admin-row { - grid-template-columns: 0.9fr 1fr 1fr 0.7fr 1.6fr 1.2fr; -} - -.notification-list { - display: grid; -} - -.notification-item { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 14px; - min-height: 86px; - padding: 16px 20px; - border-bottom: 1px solid var(--row-border); -} - -.notification-title { - color: var(--text-primary); - font-size: 14px; - font-weight: 700; -} - -.notification-content { - margin: 6px 0; - color: var(--text-secondary); - font-size: 13px; -} - -@media (max-width: 1080px) { - body { - overflow: auto; - } - - .app-shell, - .body-grid { - min-height: auto; - } - - .workspace { - overflow: visible; - } - - .kpi-grid, - .panel-grid, - .overview-charts, - .service-kpi-row, - .user-kpi-row, - .page-skeleton__kpis, - .page-skeleton__charts { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .table-scroll { - overflow-x: auto; - } - - .user-pill { - display: none; - } - - .split-grid, - .service-page-grid, - .page-skeleton__content { - grid-template-columns: 1fr; - } - - .inline-inspector { - display: none; - } -} - -@media (max-width: 760px) { - .header-tools { - gap: 8px; - padding: 0 12px; - } - - .header-location { - min-width: auto; - padding: 0 12px; - } - - .search-trigger { - min-width: 0; - width: 42px; - justify-content: center; - padding: 0; - } - - .search-trigger span { - display: none; - } - - .workspace { - padding: 16px; - } - - .page-skeleton { - height: calc(100vh - var(--header-height) - 32px); - height: calc(100dvh - var(--header-height) - 32px); - grid-template-rows: 80px minmax(96px, 0.55fr) minmax(132px, 0.75fr) minmax(180px, 1fr); - } - - .page-skeleton__head { - align-items: flex-start; - flex-direction: column; - } - - .page-skeleton__title { - width: 58%; - } - - .page-head, - .table-toolbar { - align-items: flex-start; - flex-direction: column; - } - - .kpi-grid, - .panel-grid, - .overview-charts, - .service-kpi-row, - .user-kpi-row, - .alert-strip, - .page-skeleton__kpis, - .page-skeleton__charts { - grid-template-columns: 1fr; - } - - .page-skeleton__kpis .page-skeleton__kpi:nth-child(n + 3), - .page-skeleton__charts .page-skeleton__block:nth-child(n + 2), - .page-skeleton__rail { - display: none; - } - - .page-skeleton__kpis { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .filters-panel { - align-items: stretch; - flex-direction: column; - } - - .filters-panel .search-box { - flex-basis: auto; - width: 100%; - } - - .filter-row { - flex-wrap: wrap; - } - - .drawer { - width: min(360px, 100vw); - } -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 1ms !important; - animation-iteration-count: 1 !important; - scroll-behavior: auto !important; - transition-duration: 1ms !important; - } +@import "./layout.css"; +@import "./shared-ui.css"; +@import "../features/dashboard/dashboard.css"; +@import "../features/users/users.css"; +@import "../features/menus/menus.css"; +@import "../features/notifications/notifications.css"; +@import "./responsive.css"; + +#app, +.MuiAutocomplete-popper, +.MuiDialog-root, +.MuiMenu-root, +.MuiModal-root, +.MuiPopover-root, +.MuiPopper-root, +.MuiSnackbar-root, +.MuiTooltip-popper { + font-size: var(--admin-font-size); + line-height: var(--admin-line-height); +} + +#app :is(h1, h2, h3, h4, h5, h6, p, span, small, strong, em, label, button, input, textarea, select, option, a, li, dt, dd, th, td, div, pre, code), +.MuiAutocomplete-popper :is(h1, h2, h3, h4, h5, h6, p, span, small, strong, em, label, button, input, textarea, select, option, a, li, dt, dd, th, td, div, pre, code), +.MuiDialog-root :is(h1, h2, h3, h4, h5, h6, p, span, small, strong, em, label, button, input, textarea, select, option, a, li, dt, dd, th, td, div, pre, code), +.MuiMenu-root :is(h1, h2, h3, h4, h5, h6, p, span, small, strong, em, label, button, input, textarea, select, option, a, li, dt, dd, th, td, div, pre, code), +.MuiModal-root :is(h1, h2, h3, h4, h5, h6, p, span, small, strong, em, label, button, input, textarea, select, option, a, li, dt, dd, th, td, div, pre, code), +.MuiPopover-root :is(h1, h2, h3, h4, h5, h6, p, span, small, strong, em, label, button, input, textarea, select, option, a, li, dt, dd, th, td, div, pre, code), +.MuiPopper-root :is(h1, h2, h3, h4, h5, h6, p, span, small, strong, em, label, button, input, textarea, select, option, a, li, dt, dd, th, td, div, pre, code), +.MuiSnackbar-root :is(h1, h2, h3, h4, h5, h6, p, span, small, strong, em, label, button, input, textarea, select, option, a, li, dt, dd, th, td, div, pre, code), +.MuiTooltip-popper :is(h1, h2, h3, h4, h5, h6, p, span, small, strong, em, label, button, input, textarea, select, option, a, li, dt, dd, th, td, div, pre, code) { + font-size: var(--admin-font-size); } diff --git a/src/styles/base.css b/src/styles/base.css index f0898fb..2d59317 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -14,7 +14,9 @@ body { overflow: hidden; background: var(--bg-page); color: var(--text-primary); + font-size: var(--admin-font-size); font-family: var(--font-system); + line-height: var(--admin-line-height); letter-spacing: 0; } @@ -31,7 +33,7 @@ button { button:focus-visible, input:focus-visible, select:focus-visible { - outline: 2px solid rgba(37, 99, 235, 0.7); + outline: 2px solid var(--primary-border-strong); outline-offset: 2px; } diff --git a/src/styles/layout.css b/src/styles/layout.css new file mode 100644 index 0000000..e852eca --- /dev/null +++ b/src/styles/layout.css @@ -0,0 +1,867 @@ +.app-shell { + display: grid; + grid-template-rows: var(--header-height) 1fr; + min-height: 100vh; + background: var(--bg-page); +} + +@keyframes page-enter { + from { + opacity: 0; + transform: translateY(10px) scale(0.995); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes panel-enter { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes row-enter { + from { + opacity: 0; + transform: translateX(-8px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes shimmer { + from { + background-position: 120% 0; + } + to { + background-position: -120% 0; + } +} + +@keyframes notice-pop { + 0%, + 100% { + transform: scale(1); + } + 45% { + transform: scale(1.18); + } +} + +@keyframes progress-fill { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} + +@keyframes autocomplete-pop-enter { + from { + opacity: 0; + transform: translateY(-6px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes autocomplete-pop-enter-up { + from { + opacity: 0; + transform: translateY(6px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.header { + display: grid; + grid-template-columns: 248px 1fr; + height: var(--header-height); + border-bottom: 1px solid var(--border); + background: var(--bg-header); + transition: grid-template-columns var(--motion-slow) var(--ease-emphasized); +} + +.brand { + display: flex; + align-items: center; + gap: var(--space-3); + height: var(--header-height); + padding: 0 var(--space-6); + border-right: 1px solid var(--border); + background: var(--bg-sidebar); +} + +.brand-mark { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border-radius: var(--radius-control); + background: var(--brand-gradient); + color: var(--active-contrast); + transition: + transform var(--motion-base) var(--ease-emphasized), + box-shadow var(--motion-base) var(--ease-standard); +} + +.brand:hover .brand-mark { + box-shadow: var(--brand-shadow); + transform: rotate(-6deg) scale(1.05); +} + +.brand-title { + display: block; + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 650; +} + +.brand-meta { + display: block; + margin-top: calc(var(--space-1) / 2); + color: var(--text-tertiary); + font-size: var(--admin-font-size); +} + +.header-tools { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: 0 var(--space-6); +} + +.header-left-tools, +.header-right-tools { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-3); +} + +.header-right-tools { + margin-left: auto; + justify-content: flex-end; +} + +.app-switch { + flex: 0 0 156px; +} + +.app-switch .MuiOutlinedInput-root { + background: var(--bg-input); +} + +.app-switch .MuiSelect-select { + font-weight: 650; +} + +.header-location { + display: inline-flex; + min-width: 74px; + height: var(--control-height); + align-items: center; + justify-content: center; + padding: 0 var(--space-4); + border: 1px solid var(--primary-border); + border-radius: var(--radius-control); + background: var(--primary-hover); + box-shadow: + var(--shadow-soft), + var(--active-inset); + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 650; + white-space: nowrap; +} + +.search-box { + display: flex; + flex: 1 1 420px; + min-width: 180px; + max-width: 520px; + height: var(--control-height); + align-items: center; + gap: var(--space-3); + padding: 0 var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-input); + backdrop-filter: var(--glass-blur); + color: var(--text-tertiary); + -webkit-backdrop-filter: var(--glass-blur); + transition: + border-color var(--motion-base) var(--ease-standard), + background var(--motion-base) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.search-box:focus-within { + border-color: var(--primary-border-active); + background: var(--bg-input-strong); + transform: translateY(-1px); +} + +.search-box svg { + transition: + color var(--motion-base) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.search-box:focus-within svg { + color: var(--primary); + transform: scale(1.08); +} + +.search-trigger { + display: flex; + width: min(360px, 34vw); + min-width: 260px; + height: var(--control-height); + align-items: center; + gap: var(--space-3); + padding: 0 var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-input); + backdrop-filter: var(--glass-blur); + color: var(--text-tertiary); + cursor: pointer; + text-align: left; + -webkit-backdrop-filter: var(--glass-blur); + transition: + border-color var(--motion-base) var(--ease-standard), + background var(--motion-base) var(--ease-standard), + color var(--motion-base) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.search-trigger span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-trigger:hover, +.search-trigger:focus-visible { + border-color: var(--primary-border-active); + background: var(--bg-input-strong); + color: var(--text-primary); + transform: translateY(-1px); +} + +.search-box input { + width: 100%; + height: 20px; + min-width: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--text-primary); + font-size: var(--admin-font-size); + line-height: 20px; + outline: 0; +} + +.search-box input::placeholder { + color: var(--text-tertiary); +} + +.time-select { + width: 116px; + height: var(--control-height); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-input); + backdrop-filter: var(--glass-blur); + color: var(--text-secondary); + -webkit-backdrop-filter: var(--glass-blur); +} + +.icon-button { + display: inline-grid; + width: var(--control-height); + height: var(--control-height); + place-items: center; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card); + color: var(--text-secondary); + cursor: pointer; + transition: + background var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard), + border-color var(--motion-fast) var(--ease-standard), + box-shadow var(--motion-fast) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.icon-button:hover { + border-color: var(--primary-border-strong); + background: var(--bg-hover); + box-shadow: var(--shadow-soft); + color: var(--text-primary); + transform: translateY(-1px); +} + +.icon-button:active { + box-shadow: none; + transform: translateY(0) scale(0.96); +} + +.icon-button--notice { + position: relative; +} + +.icon-button--notice span { + position: absolute; + top: -5px; + right: -5px; + display: grid; + min-width: 17px; + height: 17px; + place-items: center; + border-radius: var(--radius-pill); + background: var(--danger); + color: var(--active-contrast); + font-size: var(--admin-font-size); + font-weight: 700; + animation: notice-pop 1500ms var(--ease-emphasized) infinite; +} + +.user-pill { + display: flex; + height: var(--control-height); + align-items: center; + gap: var(--space-2); + padding: 0 var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: var(--admin-font-size); + transition: + border-color var(--motion-base) var(--ease-standard), + background var(--motion-base) var(--ease-standard), + color var(--motion-base) var(--ease-standard); +} + +.user-pill:hover { + border-color: var(--primary-border); + background: var(--primary-hover); + color: var(--text-primary); +} + +.user-pill:active { + transform: scale(0.98); +} + +.user-menu .MuiPaper-root { + min-width: 132px; + margin-top: var(--space-2); +} + +.user-menu__item { + display: flex; + gap: var(--space-2); + min-height: var(--control-height); + color: var(--text-secondary); + font-size: var(--admin-font-size); +} + +.user-menu__item:hover { + color: var(--text-primary); +} + +.body-grid { + display: grid; + grid-template-columns: 248px 1fr; + min-height: 0; + transition: grid-template-columns var(--motion-slow) var(--ease-emphasized); +} + +.sidebar { + position: relative; + z-index: var(--z-sidebar); + min-height: 0; + overflow: visible; + padding: var(--space-4) var(--space-3); + border-right: 1px solid var(--border); + background: var(--bg-sidebar); +} + +.app-shell--sidebar-collapsed .header, +.app-shell--sidebar-collapsed .body-grid { + grid-template-columns: 84px 1fr; +} + +.app-shell--sidebar-collapsed .brand { + gap: 0; + justify-content: center; + padding: 0; +} + +.brand-text, +.nav-label { + display: inline-grid; + min-width: 0; + max-width: 160px; + opacity: 1; + overflow: hidden; + transform: translateX(0); + transition: + max-width var(--motion-slow) var(--ease-emphasized), + opacity var(--motion-base) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.app-shell--sidebar-collapsed .brand-text, +.sidebar--collapsed .nav-label { + max-width: 0; + opacity: 0; + pointer-events: none; + transform: translateX(-6px); +} + +.sidebar--collapsed { + padding: var(--space-4) var(--space-3); +} + +.sidebar--collapsed .nav-item { + width: 44px; + justify-content: center; + justify-self: center; + padding: 0; +} + +.nav-list { + display: grid; + gap: var(--space-3); +} + +.nav-group { + display: grid; + position: relative; + gap: 0; +} + +.nav-item { + position: relative; + display: flex; + width: 100%; + height: 40px; + align-items: center; + gap: 10px; + padding: 0 var(--space-3); + border-radius: var(--radius-control); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + overflow: hidden; + transition: + background var(--motion-base) var(--ease-standard), + color var(--motion-base) var(--ease-standard), + gap var(--motion-slow) var(--ease-emphasized), + padding var(--motion-slow) var(--ease-emphasized), + width var(--motion-slow) var(--ease-emphasized), + transform var(--motion-base) var(--ease-emphasized); +} + +.nav-item svg { + flex: 0 0 auto; + color: currentColor; + font-size: 20px; +} + +.nav-item--parent { + padding-right: var(--space-3); + font-weight: 650; +} + +.nav-item--open:not(.nav-item--active) { + background: var(--primary-surface); + color: var(--text-secondary); +} + +.nav-expand { + margin-left: auto; + color: currentColor; + transition: + color var(--motion-base) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.nav-item--open .nav-expand, +.nav-item:hover .nav-expand { + color: currentColor; +} + +.nav-item--expanded .nav-expand { + transform: rotate(180deg); +} + +.nav-children { + display: grid; + position: relative; + grid-template-rows: 0fr; + margin: 0; + opacity: 0; + overflow: hidden; + padding-left: var(--space-7); + pointer-events: none; + transform: translateY(-4px); + transition: + grid-template-rows var(--motion-slow) var(--ease-emphasized), + opacity var(--motion-base) var(--ease-standard), + transform var(--motion-slow) var(--ease-emphasized); +} + +.nav-children::before { + position: absolute; + top: var(--space-1); + bottom: var(--space-1); + left: var(--space-4); + width: 1px; + background: var(--border-soft); + content: ""; +} + +.nav-children--open { + grid-template-rows: 1fr; + margin-top: var(--space-2); + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.nav-children__inner { + display: grid; + gap: var(--space-1); + min-height: 0; + overflow: hidden; +} + +.nav-item--child { + height: 34px; + padding: 0 var(--space-3); + font-size: var(--admin-font-size); + font-weight: 600; +} + +.nav-item--child svg { + font-size: 16px; +} + +.nav-item--child::before { + display: none; +} + +.sidebar--collapsed .nav-children, +.sidebar--collapsed .nav-item--parent .nav-expand { + display: none !important; +} + +.sidebar--collapsed .nav-item--open, +.sidebar--collapsed .nav-item--open:hover, +.sidebar--collapsed .nav-item--flyout-open, +.sidebar--collapsed .nav-item--flyout-open:hover { + background: var(--active-gradient); + box-shadow: var(--active-inset); + color: var(--active-contrast); +} + +.nav-flyout { + position: absolute; + top: 0; + left: calc(100% + var(--space-3)); + z-index: var(--z-popover); + display: grid; + width: 168px; + gap: var(--space-1); + padding: var(--space-2); + border: 1px solid var(--primary-border); + border-radius: var(--radius-control); + background: var(--bg-flyout); + box-shadow: var(--shadow-panel); + animation: panel-enter var(--motion-base) var(--ease-emphasized) both; +} + +.nav-flyout::before { + position: absolute; + top: 16px; + left: -6px; + width: 10px; + height: 10px; + border-bottom: 1px solid var(--primary-border); + border-left: 1px solid var(--primary-border); + background: var(--bg-flyout); + content: ""; + transform: rotate(45deg); +} + +.nav-flyout__title { + padding: var(--space-1) var(--space-2) var(--space-2); + color: var(--text-tertiary); + font-size: var(--admin-font-size); + font-weight: 650; +} + +.nav-flyout__item { + display: flex; + height: var(--control-height); + align-items: center; + gap: var(--space-3); + padding: 0 var(--space-3); + border-radius: var(--radius-control); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: var(--admin-font-size); + text-align: left; + transition: + background var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.nav-flyout__item:hover, +.nav-flyout__item--active { + background: var(--primary-surface); + color: var(--text-primary); + transform: translateX(2px); +} + +.nav-item::before { + position: absolute; + inset: 8px auto 8px 0; + width: 3px; + border-radius: 0 var(--radius-pill) var(--radius-pill) 0; + background: var(--primary); + content: ""; + opacity: 0; + transform: translateX(-4px); + transition: + opacity var(--motion-base) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.nav-item:hover { + background: var(--bg-hover); + color: var(--text-secondary); + transform: translateX(2px); +} + +.nav-item--child:hover { + transform: none; +} + +.nav-item--active, +.nav-item--active:hover { + background: var(--active-gradient); + box-shadow: var(--active-inset); + color: var(--active-contrast); +} + +.nav-item--child.nav-item--active, +.nav-item--child.nav-item--active:hover { + background: var(--primary); + box-shadow: 0 8px 18px rgba(37, 99, 235, 0.2); + color: var(--active-contrast); + transform: none; +} + +.nav-item:hover::before, +.nav-item--active::before { + opacity: 1; + transform: translateX(0); +} + +.nav-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace { + position: relative; + min-width: 0; + min-height: 0; + overflow: auto; + padding: var(--space-6); +} + +.page-transition { + animation: page-enter var(--motion-slow) var(--ease-emphasized) both; +} + +.page-skeleton { + display: grid; + height: calc(100vh - var(--header-height) - var(--space-8) - var(--space-4)); + height: calc(100dvh - var(--header-height) - var(--space-8) - var(--space-4)); + grid-template-rows: 38px minmax(96px, 0.7fr) minmax(160px, 1fr) minmax(200px, 1.2fr); + gap: var(--space-4); + animation: page-enter var(--motion-slow) var(--ease-emphasized) both; +} + +.page-skeleton__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); +} + +.page-skeleton__title { + width: min(220px, 36%); + height: 30px; +} + +.page-skeleton__actions { + display: flex; + gap: var(--space-3); +} + +.page-skeleton__actions .page-skeleton__block { + width: 96px; + height: var(--control-height); +} + +.page-skeleton__kpis { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--space-4); +} + +.page-skeleton__charts { + display: grid; + grid-template-columns: minmax(280px, 1.15fr) repeat(2, minmax(260px, 1fr)) minmax(260px, 0.95fr); + gap: var(--space-4); + min-height: 0; +} + +.page-skeleton__content { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: var(--space-4); + min-height: 0; +} + +.page-skeleton__block { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background-color: var(--skeleton-surface); + opacity: 1; + transform: translateZ(0); + animation: panel-enter var(--motion-slow) var(--ease-emphasized) both; +} + +.page-skeleton__block::after { + background: var(--skeleton-gradient); +} + +.page-skeleton__kpi:nth-child(2), +.page-skeleton__charts .page-skeleton__block:nth-child(2), +.page-skeleton__content .page-skeleton__block:nth-child(2) { + animation-delay: 40ms; +} + +.page-skeleton__kpi:nth-child(3), +.page-skeleton__charts .page-skeleton__block:nth-child(3) { + animation-delay: 80ms; +} + +.page-skeleton__kpi:nth-child(4), +.page-skeleton__charts .page-skeleton__block:nth-child(4) { + animation-delay: 120ms; +} + +.page-skeleton__kpi:nth-child(5) { + animation-delay: 160ms; +} + +.search-dialog { + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-dialog); + background: var(--bg-card); + background-image: none; + box-shadow: var(--shadow-dialog); + animation: panel-enter var(--motion-slow) var(--ease-emphasized) both; +} + +.search-dialog__head { + padding: var(--space-5); + border-bottom: 1px solid var(--border); + background: var(--bg-card-strong); +} + +.search-dialog__input { + max-width: none; + width: 100%; + height: 44px; +} + +.search-dialog__list { + display: grid; + gap: var(--space-2); + max-height: min(56vh, 520px); + overflow: auto; + padding: var(--space-3); +} + +.search-dialog__item { + min-height: 64px; + border: 1px solid transparent; + border-radius: var(--radius-card); + color: var(--text-primary); + transition: + background var(--motion-base) var(--ease-standard), + border-color var(--motion-base) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.search-dialog__item:hover { + border-color: var(--primary-border-strong); + background: var(--primary-hover); + transform: translateX(3px); +} + +.search-dialog__primary { + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 650; +} + +.search-dialog__secondary { + color: var(--text-tertiary); + font-size: var(--admin-font-size); +} + +.search-dialog__empty { + display: grid; + min-height: 120px; + place-items: center; + color: var(--text-tertiary); + font-size: var(--admin-font-size); +} diff --git a/src/styles/modules.d.ts b/src/styles/modules.d.ts new file mode 100644 index 0000000..6178fd4 --- /dev/null +++ b/src/styles/modules.d.ts @@ -0,0 +1,4 @@ +declare module "*.module.css" { + const styles: Record; + export default styles; +} diff --git a/src/styles/responsive.css b/src/styles/responsive.css new file mode 100644 index 0000000..7ee4ece --- /dev/null +++ b/src/styles/responsive.css @@ -0,0 +1,137 @@ +@media (max-width: 1080px) { + body { + overflow: auto; + } + + .app-shell, + .body-grid { + min-height: auto; + } + + .workspace { + overflow: visible; + } + + .kpi-grid, + .panel-grid, + .overview-charts, + .user-kpi-row, + .page-skeleton__kpis, + .page-skeleton__charts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .table-scroll { + overflow-x: auto; + } + + .user-pill { + display: none; + } + + .split-grid, + .page-skeleton__content { + grid-template-columns: 1fr; + } + + .inline-inspector { + display: none; + } +} + +@media (max-width: 760px) { + .header-tools { + gap: 8px; + padding: 0 12px; + } + + .header-location { + min-width: auto; + padding: 0 12px; + } + + .search-trigger { + min-width: 0; + width: 42px; + justify-content: center; + padding: 0; + } + + .search-trigger span { + display: none; + } + + .workspace { + padding: 16px; + } + + .page-skeleton { + height: calc(100vh - var(--header-height) - 32px); + height: calc(100dvh - var(--header-height) - 32px); + grid-template-rows: 80px minmax(96px, 0.55fr) minmax(132px, 0.75fr) minmax(180px, 1fr); + } + + .page-skeleton__head { + align-items: flex-start; + flex-direction: column; + } + + .page-skeleton__title { + width: 58%; + } + + .page-head, + .table-toolbar { + align-items: flex-start; + flex-direction: column; + } + + .kpi-grid, + .panel-grid, + .overview-charts, + .user-kpi-row, + .alert-strip, + .page-skeleton__kpis, + .page-skeleton__charts { + grid-template-columns: 1fr; + } + + .page-skeleton__kpis .page-skeleton__kpi:nth-child(n + 3), + .page-skeleton__charts .page-skeleton__block:nth-child(n + 2), + .page-skeleton__rail { + display: none; + } + + .page-skeleton__kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .filters-panel { + align-items: stretch; + flex-direction: column; + } + + .filters-panel .search-box { + flex-basis: auto; + width: 100%; + } + + .filter-row { + flex-wrap: wrap; + } + + .drawer { + width: min(360px, 100vw); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 1ms !important; + } +} diff --git a/src/styles/shared-ui.css b/src/styles/shared-ui.css new file mode 100644 index 0000000..0c197e5 --- /dev/null +++ b/src/styles/shared-ui.css @@ -0,0 +1,944 @@ +.page-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-5); +} + +.page-title { + margin: 0; + font-size: var(--admin-font-size); + font-weight: 700; +} + +.page-meta { + margin: var(--space-2) 0 0; + color: var(--text-tertiary); + font-size: var(--admin-font-size); +} + +.page-actions { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.button { + display: inline-flex; + height: var(--control-height); + min-height: var(--control-height); + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: 0 var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card); + color: var(--text-secondary); + cursor: pointer; + font-size: var(--admin-font-size); + line-height: var(--admin-line-height); + white-space: nowrap; + transition: + background var(--motion-fast) var(--ease-standard), + border-color var(--motion-fast) var(--ease-standard), + box-shadow var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.button:hover { + border-color: var(--primary-border-strong); + background: var(--bg-hover); + box-shadow: var(--shadow-soft); + color: var(--text-primary); + transform: translateY(-1px); +} + +.button:active { + box-shadow: none; + transform: translateY(0) scale(0.985); +} + +.button:disabled, +.icon-button:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.button--primary { + border-color: var(--primary); + background: var(--primary); + color: var(--active-contrast); +} + +.button--success { + color: var(--success); +} + +.button--danger { + color: var(--danger); +} + +.button.MuiButton-root { + height: var(--control-height); + min-height: var(--control-height); + padding: 0 var(--space-3); + border-radius: var(--radius-control); + font-size: var(--admin-font-size); + line-height: var(--admin-line-height); + text-transform: none; +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--space-4); +} + +.card { + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); +} + +.MuiTextField-root, +.MuiFormControl-root { + --admin-control-height: var(--control-height); + --admin-control-font-size: var(--admin-font-size); + --admin-control-line-height: var(--admin-line-height); +} + +.region-select.MuiTextField-root, +.region-select.MuiFormControl-root { + width: 180px; + min-width: 180px; + flex: 0 0 180px; +} + +.MuiInputBase-root.MuiOutlinedInput-root:not(.MuiInputBase-multiline) { + height: var(--control-height); + min-height: var(--control-height); +} + +.MuiInputBase-root.MuiOutlinedInput-root { + background: var(--bg-input); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); +} + +.MuiInputBase-root.MuiOutlinedInput-root:not(.MuiInputBase-multiline) .MuiInputBase-input { + box-sizing: border-box; + height: var(--control-height); + padding-top: 0; + padding-bottom: 0; + font-size: var(--admin-font-size); + line-height: var(--admin-line-height); +} + +.MuiInputBase-root .MuiInputBase-input::placeholder { + font-size: var(--admin-font-size); + line-height: var(--admin-line-height); +} + +.MuiInputBase-input:focus { + outline: 0; +} + +.MuiTextField-root .MuiOutlinedInput-root, +.MuiFormControl-root .MuiOutlinedInput-root { + border-radius: var(--radius-control); + background: var(--bg-input); + backdrop-filter: var(--glass-blur); + color: var(--text-primary); + font-size: var(--admin-control-font-size); + line-height: var(--admin-control-line-height); + -webkit-backdrop-filter: var(--glass-blur); +} + +.MuiTextField-root .MuiOutlinedInput-root:not(.MuiInputBase-multiline), +.MuiFormControl-root .MuiOutlinedInput-root:not(.MuiInputBase-multiline) { + height: var(--admin-control-height); + min-height: var(--admin-control-height); +} + +.MuiTextField-root .MuiOutlinedInput-notchedOutline, +.MuiFormControl-root .MuiOutlinedInput-notchedOutline { + border-color: var(--border); +} + +.MuiTextField-root .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline, +.MuiFormControl-root .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline { + border-color: var(--primary-border); +} + +.MuiTextField-root .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline, +.MuiFormControl-root .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline { + border-color: var(--primary); + border-width: 1px; +} + +.MuiInputBase-root.Mui-focused { + box-shadow: none; +} + +.MuiTextField-root .MuiOutlinedInput-input, +.MuiFormControl-root .MuiOutlinedInput-input { + box-sizing: border-box; + height: var(--admin-control-height); + padding: 0 var(--space-3); + color: var(--text-primary); + font-size: var(--admin-control-font-size); + line-height: var(--admin-control-line-height); +} + +.MuiTextField-root .MuiOutlinedInput-input::placeholder, +.MuiFormControl-root .MuiOutlinedInput-input::placeholder { + color: var(--text-tertiary); + font-size: var(--admin-control-font-size); + line-height: var(--admin-control-line-height); + opacity: 1; +} + +.MuiTextField-root .MuiInputLabel-outlined, +.MuiFormControl-root .MuiInputLabel-outlined { + color: var(--text-tertiary); + font-size: var(--admin-control-font-size); + line-height: var(--admin-control-line-height); + transform: translate(14px, 8px) scale(1); +} + +.MuiTextField-root .MuiInputLabel-outlined.MuiInputLabel-shrink, +.MuiFormControl-root .MuiInputLabel-outlined.MuiInputLabel-shrink { + transform: translate(14px, -8px) scale(0.75); +} + +.MuiTextField-root .MuiFormLabel-root.Mui-focused, +.MuiFormControl-root .MuiFormLabel-root.Mui-focused { + color: var(--primary); +} + +.MuiTextField-root .MuiSelect-select, +.MuiFormControl-root .MuiSelect-select { + display: flex; + box-sizing: border-box; + height: var(--admin-control-height); + min-height: var(--admin-control-height) !important; + align-items: center; + padding: 0 var(--space-8) 0 var(--space-3) !important; + font-size: var(--admin-control-font-size); + line-height: var(--admin-control-line-height); +} + +.MuiTextField-root .MuiSelect-icon, +.MuiFormControl-root .MuiSelect-icon { + top: 50%; + color: var(--text-tertiary); + transform: translateY(-50%); +} + +.MuiAutocomplete-root { + --admin-autocomplete-chip-height: 28px; + --admin-autocomplete-input-height: 24px; +} + +.MuiAutocomplete-root .MuiOutlinedInput-root.MuiAutocomplete-inputRoot:not(.MuiInputBase-multiline) { + height: auto; + min-height: var(--control-height); + align-items: flex-start; + flex-wrap: wrap; + gap: var(--space-1); + padding: 5px 40px 5px var(--space-2); +} + +.MuiAutocomplete-root .MuiOutlinedInput-root.MuiAutocomplete-inputRoot .MuiAutocomplete-input { + flex: 1 0 96px; + width: auto !important; + min-width: 80px; + height: var(--admin-autocomplete-input-height); + padding: 0 !important; + line-height: var(--admin-autocomplete-input-height); +} + +.MuiAutocomplete-root .MuiOutlinedInput-root.MuiAutocomplete-inputRoot .MuiAutocomplete-endAdornment { + top: 50%; + right: var(--space-2); + transform: translateY(-50%); +} + +.MuiAutocomplete-root .MuiAutocomplete-tag { + max-width: calc(100% - var(--space-2)); + height: var(--admin-autocomplete-chip-height); + margin: 0; + border-radius: var(--radius-pill); + background: var(--neutral-surface-strong); + color: var(--text-primary); + font-size: var(--admin-font-size); +} + +.MuiAutocomplete-root .MuiAutocomplete-tag .MuiChip-label { + padding: 0 var(--space-2); +} + +.MuiAutocomplete-root .MuiAutocomplete-tag .MuiChip-deleteIcon { + width: 18px; + height: 18px; + margin-right: var(--space-1); + color: var(--text-tertiary); +} + +.MuiAutocomplete-popper .MuiAutocomplete-paper { + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-flyout); + box-shadow: var(--shadow-panel); + transform-origin: top center; + animation: autocomplete-pop-enter var(--motion-base) var(--ease-emphasized) both; +} + +.MuiAutocomplete-popper[data-popper-placement*="top"] .MuiAutocomplete-paper { + transform-origin: bottom center; + animation-name: autocomplete-pop-enter-up; +} + +.MuiAutocomplete-popper .MuiAutocomplete-listbox { + max-height: 280px; + padding: var(--space-1); +} + +.MuiAutocomplete-popper .MuiAutocomplete-option { + min-height: var(--control-height); + border-radius: var(--radius-control); + color: var(--text-secondary); + font-size: var(--admin-font-size); + transition: + background var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard), + transform var(--motion-fast) var(--ease-emphasized); +} + +.MuiAutocomplete-popper .MuiAutocomplete-option.Mui-focused, +.MuiAutocomplete-popper .MuiAutocomplete-option[aria-selected="true"] { + background: var(--primary-hover); + color: var(--text-primary); + transform: translateX(2px); +} + +.MuiTextField-root .MuiOutlinedInput-root.MuiInputBase-multiline, +.MuiFormControl-root .MuiOutlinedInput-root.MuiInputBase-multiline { + align-items: flex-start; + padding: var(--space-2) var(--space-3); +} + +.MuiTextField-root .MuiOutlinedInput-root.MuiInputBase-multiline .MuiOutlinedInput-input, +.MuiFormControl-root .MuiOutlinedInput-root.MuiInputBase-multiline .MuiOutlinedInput-input { + height: auto; + min-height: calc(var(--admin-line-height) * 2); + padding: 0; + line-height: var(--admin-control-line-height); +} + +.MuiFormHelperText-root { + margin: var(--space-1) 0 0; + color: var(--text-tertiary); + font-size: var(--admin-font-size); + line-height: var(--admin-line-height); +} + +.MuiMenuItem-root { + min-height: var(--control-height); + font-size: var(--admin-font-size); +} + +@media (max-width: 760px) { + .region-select.MuiTextField-root, + .region-select.MuiFormControl-root { + width: 100%; + min-width: 0; + flex: 1 1 100%; + } + +} + +.kpi-card { + min-height: 132px; + 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); +} + +.kpi-grid .kpi-card:nth-child(2) { + animation-delay: 40ms; +} + +.kpi-grid .kpi-card:nth-child(3) { + animation-delay: 80ms; +} + +.kpi-grid .kpi-card:nth-child(4) { + animation-delay: 120ms; +} + +.kpi-grid .kpi-card:nth-child(5) { + animation-delay: 160ms; +} + +.kpi-card:hover, +.chart-card:hover, +.table-card:hover, +.server-rail:hover, +.inline-inspector:hover { + border-color: var(--primary-border-strong); + box-shadow: var(--shadow-panel); + transform: translateY(-2px); +} + +.kpi-card__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.kpi-card__label, +.card-title { + color: var(--text-secondary); + font-size: var(--admin-font-size); + font-weight: 650; +} + +.metric-icon { + display: grid; + width: var(--control-height); + height: var(--control-height); + place-items: center; + border-radius: var(--radius-control); + background: var(--primary-surface); + color: var(--primary); + transition: + background var(--motion-base) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.kpi-card:hover .metric-icon { + transform: scale(1.08) rotate(-4deg); +} + +.metric-icon--success { + background: var(--success-surface); + color: var(--success); +} + +.metric-icon--warning { + background: var(--warning-surface); + color: var(--warning); +} + +.metric-icon--info { + background: var(--info-surface); + color: var(--info); +} + +.kpi-value { + margin-top: var(--space-5); + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 760; + line-height: 1; +} + +.kpi-value span { + margin-left: var(--space-1); + font-size: var(--admin-font-size); + font-weight: 600; +} + +.kpi-sub { + margin-top: var(--space-3); + color: var(--text-tertiary); + font-size: var(--admin-font-size); +} + +.status-ok { + color: var(--success); +} + +.status-warn { + color: var(--warning); +} + +.status-danger { + color: var(--danger); +} + +.status-badge { + display: inline-flex; + height: var(--status-height); + align-items: center; + gap: var(--space-2); + padding: 0 var(--space-3); + border: 1px solid var(--border) !important; + border-radius: var(--radius-pill) !important; + background: var(--neutral-surface) !important; + color: var(--text-secondary) !important; + font-size: var(--admin-font-size) !important; +} + +.status-point { + width: var(--space-2); + height: var(--space-2); + border-radius: var(--radius-pill); + background: var(--stopped); +} + +.status-badge--running, +.status-badge--succeeded { + border-color: var(--success-border) !important; + background: var(--success-surface) !important; + color: var(--success) !important; +} + +.status-badge--running .status-point, +.status-badge--succeeded .status-point { + background: var(--success); + box-shadow: 0 0 var(--space-3) var(--success-glow); +} + +.status-badge--warning { + border-color: var(--warning-border) !important; + background: var(--warning-surface) !important; + color: var(--warning) !important; +} + +.status-badge--warning .status-point { + background: var(--warning); + box-shadow: 0 0 var(--space-3) var(--warning-glow); +} + +.status-badge--error, +.status-badge--danger { + border-color: var(--danger-border) !important; + background: var(--danger-surface) !important; + color: var(--danger) !important; +} + +.status-badge--error .status-point, +.status-badge--danger .status-point { + background: var(--danger); + box-shadow: 0 0 var(--space-3) var(--danger-glow); +} + +.status-badge--stopped { + background: var(--neutral-surface) !important; + color: var(--stopped) !important; +} + +.panel-grid { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(340px, 0.65fr); + gap: var(--space-4); + margin-top: var(--space-4); +} + +.table-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); +} + +.filter-row { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.filter-chip { + height: var(--status-height); + padding: 0 var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + font-size: var(--admin-font-size); + transition: + background var(--motion-fast) var(--ease-standard), + border-color var(--motion-fast) var(--ease-standard), + box-shadow var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.filter-chip:hover { + box-shadow: var(--shadow-soft); + transform: translateY(-1px); +} + +.filter-chip--active { + border-color: var(--primary-border-strong); + background: var(--primary-surface); + color: var(--text-primary); +} + +.muted { + color: var(--text-tertiary); +} + +.row-actions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.empty-state { + padding: var(--space-8) var(--space-5); + color: var(--text-tertiary); + text-align: center; +} + +.login-page { + display: grid; + min-height: 100vh; + place-items: center; + padding: var(--space-6); + background: + var(--login-gradient), + var(--bg-page); +} + +.login-panel { + display: grid; + width: min(420px, 100%); + gap: var(--space-6); + padding: var(--space-7); + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); + box-shadow: var(--shadow-panel); + animation: panel-enter var(--motion-slow) var(--ease-emphasized) both; +} + +.login-brand { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.login-brand h1 { + margin: 0; + color: var(--text-primary); + font-size: var(--admin-font-size); +} + +.login-brand p { + margin: var(--space-1) 0 0; + color: var(--text-tertiary); + font-size: var(--admin-font-size); +} + +.login-form, +.form-drawer { + display: grid; + gap: var(--space-4); +} + +.form-drawer { + width: min(420px, 100vw); + padding: var(--space-6); +} + +.form-drawer--wide { + width: min(560px, 100vw); +} + +.form-drawer h2 { + margin: 0 0 var(--space-1); + color: var(--text-primary); + font-size: var(--admin-font-size); +} + +.form-drawer__actions { + display: flex; + justify-content: flex-end; + gap: var(--space-3); + padding-top: var(--space-2); +} + +.side-drawer { + display: flex; + width: min(420px, 100vw); + min-height: 100%; + flex-direction: column; + gap: var(--space-4); + padding: var(--space-6); + background: var(--bg-card); +} + +.side-drawer--wide { + width: min(560px, 100vw); +} + +.side-drawer__header { + display: flex; + min-height: var(--control-height); + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.side-drawer__title { + margin: 0; + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 800; +} + +.side-drawer__body { + display: grid; + gap: var(--space-4); +} + +.side-drawer__actions { + display: flex; + justify-content: flex-end; + gap: var(--space-3); + margin-top: auto; + padding-top: var(--space-2); +} + +.form-section-title { + color: var(--text-secondary); + font-size: var(--admin-font-size); + font-weight: 700; +} + +.permission-menu-list { + display: grid; + gap: var(--space-3); +} + +.permission-menu-section { + display: grid; + gap: var(--space-2); + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); +} + +.permission-menu-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + min-height: var(--control-height); +} + +.permission-menu-title { + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 700; +} + +.permission-menu-actions { + display: inline-flex; + align-items: center; + gap: var(--space-3); +} + +.permission-menu-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-2); + color: var(--text-secondary); + font-size: var(--admin-font-size); + font-weight: 600; + white-space: nowrap; +} + +.permission-menu-toggle .MuiCheckbox-root, +.permission-check .MuiCheckbox-root { + padding: 0; +} + +.permission-menu-count { + color: var(--text-tertiary); + font-size: var(--admin-font-size); + white-space: nowrap; +} + +.permission-section { + display: grid; + gap: var(--space-3); +} + +.permission-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); +} + +.permission-grid--compact { + gap: var(--space-2); +} + +.permission-check { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: var(--control-height); + padding: 0 var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong); + color: var(--text-secondary); + font-size: var(--admin-font-size); +} + +.batch-actions { + display: flex; + gap: var(--space-2); +} + +.pagination-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + margin-top: var(--space-3); + color: var(--text-tertiary); + font-size: var(--admin-font-size); +} + +.pagination-bar__actions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.data-state { + display: grid; + min-height: 180px; + place-items: center; + gap: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); + color: var(--text-tertiary); +} + +.data-state--loading { + padding: var(--space-4); +} + +.data-state--loading .page-skeleton__block { + min-height: 220px; +} + +.data-state__title { + color: var(--text-secondary); + font-size: var(--admin-font-size); + font-weight: 650; +} + +.admin-table { + display: flex; + flex-direction: column; + width: 100%; + min-height: 100%; + min-width: var(--admin-table-min-width, 900px); +} + +.admin-row { + display: grid; + flex: 0 0 auto; + align-items: center; + grid-template-columns: var(--admin-table-columns); + min-height: var(--table-row-height); + padding: 0 var(--space-5); + border-bottom: 1px solid var(--row-border); + color: var(--text-secondary); + column-gap: var(--space-4); + font-size: var(--admin-font-size); + animation: row-enter var(--motion-slow) var(--ease-emphasized) both; +} + +.admin-row[role="button"] { + cursor: pointer; +} + +.admin-row--head { + min-height: var(--table-head-height); + background: var(--bg-card-strong); + color: var(--text-tertiary); + font-size: var(--admin-font-size); + font-weight: 650; +} + +.admin-cell { + min-width: 0; + overflow-wrap: anywhere; +} + +.admin-table .empty-state--table { + flex: 1; +} + +.table-frame { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + background: var(--bg-card); +} + +.table-frame .table-scroll { + flex: 1; + min-height: 0; + overflow: auto; +} + +.table-frame .admin-row--head { + position: sticky; + top: 0; + z-index: 1; +} + +.content-panel, +.table-frame, +.table-card { + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); +} + +.content-panel { + display: flex; + flex: 1; + flex-direction: column; + min-height: 520px; +} + +.empty-state--table { + min-height: 340px; + align-content: center; +} + +.empty-state__title { + color: var(--text-secondary); + font-weight: 650; +} + +.empty-state__action { + display: flex; + justify-content: center; +} diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 05fa1df..c72efe4 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -1,12 +1,39 @@ :root { + --admin-font-size: 14px; + --admin-line-height: 20px; + --control-height: 36px; + --status-height: 24px; + --table-head-height: 44px; + --table-row-height: 58px; + --switch-width: 46px; + --switch-height: 26px; + --switch-thumb-size: 20px; + --switch-padding: 3px; + --switch-translate-x: 20px; + + --space-0: 0; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-7: 28px; + --space-8: 32px; + + --radius-control: 8px; + --radius-card: 10px; + --radius-dialog: 12px; + --radius-pill: 999px; + --bg-page: #f4f7fb; --bg-sidebar: #ffffff; --bg-header: #ffffff; --bg-card: #ffffff; --bg-card-strong: #f1f5f9; --bg-hover: rgba(37, 99, 235, 0.08); - --bg-input: rgba(248, 250, 252, 0.92); - --bg-input-strong: #ffffff; + --bg-input: rgba(255, 255, 255, 0.58); + --bg-input-strong: rgba(255, 255, 255, 0.74); --bg-flyout: rgba(255, 255, 255, 0.98); --border: #d8e0ec; @@ -16,18 +43,49 @@ --primary: #2563eb; --primary-strong: #1d4ed8; + --primary-hover: rgba(37, 99, 235, 0.08); + --primary-surface: rgba(37, 99, 235, 0.1); + --primary-surface-strong: rgba(37, 99, 235, 0.14); + --primary-border: rgba(37, 99, 235, 0.52); + --primary-border-strong: rgba(37, 99, 235, 0.7); + --primary-border-active: rgba(37, 99, 235, 0.78); --brand-secondary: #0ea5e9; + --brand-gradient: linear-gradient(135deg, var(--primary), var(--brand-secondary)); + --brand-shadow: 0 10px 24px rgba(37, 99, 235, 0.22); + --active-gradient: linear-gradient(90deg, rgba(37, 99, 235, 0.96), rgba(14, 165, 233, 0.34)); + --active-inset: inset 0 0 0 1px rgba(14, 165, 233, 0.2); --success: #16a34a; + --success-surface: rgba(22, 163, 74, 0.1); + --success-border: rgba(22, 163, 74, 0.32); + --success-border-strong: rgba(22, 163, 74, 0.72); + --success-glow: rgba(22, 163, 74, 0.28); --warning: #d97706; + --warning-surface: rgba(217, 119, 6, 0.1); + --warning-border: rgba(217, 119, 6, 0.32); + --warning-glow: rgba(217, 119, 6, 0.28); --danger: #dc2626; + --danger-surface: rgba(220, 38, 38, 0.1); + --danger-border: rgba(220, 38, 38, 0.28); + --danger-border-strong: rgba(220, 38, 38, 0.72); + --danger-glow: rgba(220, 38, 38, 0.28); --info: #0284c7; + --info-surface: rgba(2, 132, 199, 0.1); + --info-surface-strong: rgba(2, 132, 199, 0.14); + --info-border: rgba(2, 132, 199, 0.32); --stopped: #64748b; + --neutral-surface: rgba(100, 116, 139, 0.1); + --neutral-surface-strong: rgba(100, 116, 139, 0.16); + --neutral-border: rgba(100, 116, 139, 0.22); --chart-blue: #2563eb; --chart-green: #16a34a; --chart-red: #dc2626; --chart-orange: #d97706; + --chart-grid: rgba(100, 116, 139, 0.16); + --chart-tooltip-bg: #ffffff; + --chart-tooltip-border: #d8e0ec; + --chart-tooltip-text: #0f172a; --text-primary: #0f172a; --text-secondary: #475569; @@ -40,8 +98,16 @@ --shadow-drawer: 0 24px 60px rgba(15, 23, 42, 0.18); --overlay-scrim: rgba(15, 23, 42, 0.24); --focus-ring: rgba(37, 99, 235, 0.16); + --glass-blur: blur(14px) saturate(140%); + --login-gradient: linear-gradient(180deg, rgba(37, 99, 235, 0.08), rgba(37, 99, 235, 0)); --skeleton-surface: rgba(255, 255, 255, 0.86); --skeleton-shine: rgba(37, 99, 235, 0.1); + --skeleton-gradient: linear-gradient(90deg, transparent, var(--skeleton-shine), transparent); + --z-sidebar: 25; + --z-drawer-scrim: 19; + --z-drawer: 20; + --z-popover: 30; + --z-modal: 40; --motion-fast: 150ms; --motion-base: 220ms; --motion-slow: 320ms; diff --git a/src/theme.js b/src/theme.js index 124f791..83de685 100644 --- a/src/theme.js +++ b/src/theme.js @@ -1,35 +1,42 @@ import { createTheme } from "@mui/material/styles"; +const token = (name) => `var(--${name})`; + export const theme = createTheme({ palette: { mode: "light", primary: { - main: "#2563eb", - contrastText: "#ffffff" + main: "hsl(221, 83%, 53%)", + contrastText: "hsl(0, 0%, 100%)" }, success: { - main: "#16a34a" + main: "hsl(142, 76%, 36%)" }, warning: { - main: "#d97706" + main: "hsl(32, 95%, 44%)" }, error: { - main: "#dc2626" + main: "hsl(0, 72%, 51%)" }, background: { - default: "#f4f7fb", - paper: "#ffffff" + default: "hsl(215, 47%, 97%)", + paper: "hsl(0, 0%, 100%)" }, text: { - primary: "#0f172a", - secondary: "#475569", - disabled: "#94a3b8" + primary: "hsl(222, 47%, 11%)", + secondary: "hsl(215, 25%, 35%)", + disabled: "hsl(215, 16%, 47%)" }, - divider: "#d8e0ec" + divider: "hsl(216, 37%, 89%)" }, typography: { fontFamily: '-apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif', + fontSize: 14, + allVariants: { + fontSize: 14 + }, button: { + fontSize: 14, textTransform: "none", fontWeight: 650 } @@ -45,27 +52,27 @@ export const theme = createTheme({ styleOverrides: { root: { minWidth: 0, - height: 36, - borderRadius: 8, - padding: "0 12px", - fontSize: 13, + height: token("control-height"), + borderRadius: token("radius-control"), + padding: `0 ${token("space-3")}`, + fontSize: 14, transition: "background-color 150ms cubic-bezier(0.2, 0, 0, 1), border-color 150ms cubic-bezier(0.2, 0, 0, 1), box-shadow 150ms cubic-bezier(0.2, 0, 0, 1), transform 220ms cubic-bezier(0.16, 1, 0.3, 1)" }, outlined: { - borderColor: "#d8e0ec", - color: "#475569", - backgroundColor: "#ffffff", + borderColor: token("border"), + color: token("text-secondary"), + backgroundColor: token("bg-card"), "&:hover": { - borderColor: "rgba(37, 99, 235, 0.7)", - backgroundColor: "rgba(37, 99, 235, 0.08)", - color: "#0f172a" + borderColor: token("primary-border-strong"), + backgroundColor: token("primary-hover"), + color: token("text-primary") } }, containedPrimary: { - backgroundColor: "#2563eb", + backgroundColor: token("primary"), "&:hover": { - backgroundColor: "#1d4ed8" + backgroundColor: token("primary-strong") } } } @@ -73,11 +80,11 @@ export const theme = createTheme({ MuiCard: { styleOverrides: { root: { - border: "1px solid #d8e0ec", - borderRadius: 10, - backgroundColor: "#ffffff", + border: `1px solid ${token("border")}`, + borderRadius: token("radius-card"), + backgroundColor: token("bg-card"), backgroundImage: "none", - color: "#0f172a", + color: token("text-primary"), transition: "border-color 220ms cubic-bezier(0.2, 0, 0, 1), box-shadow 220ms cubic-bezier(0.2, 0, 0, 1), transform 220ms cubic-bezier(0.16, 1, 0.3, 1)" } @@ -86,10 +93,10 @@ export const theme = createTheme({ MuiChip: { styleOverrides: { root: { - height: 24, - borderRadius: 999, - fontSize: 12, - backgroundColor: "rgba(100, 116, 139, 0.1)", + height: token("status-height"), + borderRadius: token("radius-pill"), + fontSize: 14, + backgroundColor: token("neutral-surface"), transition: "background-color 150ms cubic-bezier(0.2, 0, 0, 1), border-color 150ms cubic-bezier(0.2, 0, 0, 1), color 150ms cubic-bezier(0.2, 0, 0, 1), transform 220ms cubic-bezier(0.16, 1, 0.3, 1)" }, @@ -106,8 +113,8 @@ export const theme = createTheme({ MuiIconButton: { styleOverrides: { root: { - borderRadius: 8, - color: "#475569", + borderRadius: token("radius-control"), + color: token("text-secondary"), transition: "background-color 150ms cubic-bezier(0.2, 0, 0, 1), border-color 150ms cubic-bezier(0.2, 0, 0, 1), color 150ms cubic-bezier(0.2, 0, 0, 1), transform 220ms cubic-bezier(0.16, 1, 0.3, 1)" } @@ -115,9 +122,13 @@ export const theme = createTheme({ }, MuiInputBase: { styleOverrides: { + root: { + fontSize: 14 + }, input: { + fontSize: 14, "&::placeholder": { - color: "#94a3b8", + color: token("text-tertiary"), opacity: 1 } } @@ -126,9 +137,9 @@ export const theme = createTheme({ MuiMenu: { styleOverrides: { paper: { - border: "1px solid #d8e0ec", + border: `1px solid ${token("border")}`, backgroundImage: "none", - backgroundColor: "#ffffff", + backgroundColor: token("bg-card"), transformOrigin: "top right" } } @@ -136,11 +147,11 @@ export const theme = createTheme({ MuiSelect: { styleOverrides: { root: { - color: "#475569", - fontSize: 13 + color: token("text-secondary"), + fontSize: 14 }, icon: { - color: "#475569" + color: token("text-secondary") } } }, @@ -150,47 +161,47 @@ export const theme = createTheme({ }, styleOverrides: { root: { - width: 46, - height: 26, + width: token("switch-width"), + height: token("switch-height"), padding: 0, - margin: "0 8px", + margin: `0 ${token("space-2")}`, overflow: "visible" }, switchBase: { - padding: 3, - color: "#ffffff", + padding: token("switch-padding"), + color: token("active-contrast"), transition: "transform 220ms cubic-bezier(0.16, 1, 0.3, 1)", "&.Mui-checked": { - color: "#ffffff", - transform: "translateX(20px)", + color: token("active-contrast"), + transform: `translateX(${token("switch-translate-x")})`, "& + .MuiSwitch-track": { - borderColor: "rgba(37, 99, 235, 0.36)", - background: "linear-gradient(135deg, #2563eb, #0ea5e9)", - boxShadow: "0 8px 20px rgba(37, 99, 235, 0.22)", + borderColor: token("primary-border"), + background: token("brand-gradient"), + boxShadow: token("shadow-soft"), opacity: 1 }, "& .MuiSwitch-thumb": { - boxShadow: "0 4px 12px rgba(15, 23, 42, 0.18)" + boxShadow: token("shadow-soft") } }, "&.Mui-disabled": { - color: "#ffffff", + color: token("active-contrast"), "& + .MuiSwitch-track": { opacity: 0.5 } } }, thumb: { - width: 20, - height: 20, - backgroundColor: "#ffffff", - boxShadow: "0 3px 10px rgba(15, 23, 42, 0.18)" + width: token("switch-thumb-size"), + height: token("switch-thumb-size"), + backgroundColor: token("active-contrast"), + boxShadow: token("shadow-soft") }, track: { - border: "1px solid #d8e0ec", - borderRadius: 999, - backgroundColor: "#e2e8f0", - boxShadow: "inset 0 1px 2px rgba(15, 23, 42, 0.06)", + border: `1px solid ${token("border")}`, + borderRadius: token("radius-pill"), + backgroundColor: token("bg-card-strong"), + boxShadow: "none", opacity: 1, transition: "background 220ms cubic-bezier(0.2, 0, 0, 1), border-color 220ms cubic-bezier(0.2, 0, 0, 1), box-shadow 220ms cubic-bezier(0.2, 0, 0, 1)" @@ -200,24 +211,45 @@ export const theme = createTheme({ MuiOutlinedInput: { styleOverrides: { notchedOutline: { - borderColor: "#d8e0ec" + borderColor: token("border") }, root: { - backgroundColor: "#ffffff", + backgroundColor: token("bg-input"), + backdropFilter: token("glass-blur"), transition: - "background-color 150ms cubic-bezier(0.2, 0, 0, 1), box-shadow 220ms cubic-bezier(0.2, 0, 0, 1), transform 220ms cubic-bezier(0.16, 1, 0.3, 1)", + "background-color 150ms cubic-bezier(0.2, 0, 0, 1), border-color 220ms cubic-bezier(0.2, 0, 0, 1)", "&:hover .MuiOutlinedInput-notchedOutline": { - borderColor: "rgba(37, 99, 235, 0.7)" + borderColor: token("primary-border-strong") }, "&.Mui-focused .MuiOutlinedInput-notchedOutline": { - borderColor: "#2563eb" + borderColor: token("primary") }, "&.Mui-focused": { - boxShadow: "0 0 0 3px rgba(37, 99, 235, 0.16)", - transform: "translateY(-1px)" + boxShadow: "none" } } } + }, + MuiFormLabel: { + styleOverrides: { + root: { + fontSize: 14 + } + } + }, + MuiFormHelperText: { + styleOverrides: { + root: { + fontSize: 14 + } + } + }, + MuiMenuItem: { + styleOverrides: { + root: { + fontSize: 14 + } + } } } }); diff --git a/src/utils/time.js b/src/utils/time.js deleted file mode 100644 index 4e32e5c..0000000 --- a/src/utils/time.js +++ /dev/null @@ -1,8 +0,0 @@ -export function formatTime(date = new Date()) { - return date.toLocaleTimeString("zh-CN", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit" - }); -} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1c407b7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node", "vitest/globals", "vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src", "scripts", "contracts"] +} diff --git a/vite.config.js b/vite.config.js index 18d24d5..f3105d3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,6 +2,17 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ + css: { + modules: { + generateScopedName: "hy-[name]__[local]__[hash:base64:5]", + localsConvention: "camelCaseOnly" + } + }, + resolve: { + alias: { + "@": new URL("./src", import.meta.url).pathname + } + }, plugins: [react()], server: { port: 7001,