diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..5745caf
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+node_modules
+dist
+.git
+.DS_Store
+coverage
+run
+*.log
diff --git a/AGENTS.md b/AGENTS.md
index 027c99c..9cf84e4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -89,6 +89,7 @@ import { Refresh } from "@mui/icons-material";
- 表格空态只显示 `当前无数据`,创建入口放在页面工具栏,不放在空态里。
- 表格里的启用/禁用状态优先使用行内 Switch 快捷操作,不使用单独的启用/禁用图标按钮。
- 表格状态筛选使用 Select 下拉框,默认值为 `全部状态`,不使用状态 chip 组。
+- 后台列表页的时间区间筛选必须使用统一的单入口时间区间筛选框(`src/shared/ui/TimeRangeFilter.jsx`);不要在工具栏里并排放两个开始/结束时间输入框,也不要使用浏览器原生日期/时间输入样式。时间区间弹层内使用快捷项、日历和时分下拉点选,不使用手写时间文本框。
- 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。
- 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。
- 图片上传表单必须提供统一预览;`webp` 使用浏览器原生动效预览,`svga` 使用 SVGA 播放器预览,`pag` 使用 libpag + wasm 预览。
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..74a86df
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,22 @@
+FROM node:24-alpine AS builder
+
+WORKDIR /src
+
+RUN corepack enable
+
+COPY package.json pnpm-lock.yaml ./
+RUN pnpm install --frozen-lockfile
+
+COPY . .
+
+ARG VITE_API_BASE_URL=/api
+ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
+
+RUN pnpm build
+
+FROM nginx:1.27-alpine
+
+COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=builder /src/dist /usr/share/nginx/html
+
+EXPOSE 80
diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json
index 5b0ff2b..e8a0230 100644
--- a/contracts/admin-openapi.json
+++ b/contracts/admin-openapi.json
@@ -84,6 +84,46 @@
]
}
},
+ "/admin/activity/registration-reward/claims": {
+ "get": {
+ "operationId": "listRegistrationRewardClaims",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "registration-reward:view",
+ "x-permissions": [
+ "registration-reward:view"
+ ]
+ }
+ },
+ "/admin/activity/registration-reward/config": {
+ "get": {
+ "operationId": "getRegistrationRewardConfig",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "registration-reward:view",
+ "x-permissions": [
+ "registration-reward:view"
+ ]
+ },
+ "put": {
+ "operationId": "updateRegistrationRewardConfig",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "registration-reward:update",
+ "x-permissions": [
+ "registration-reward:update"
+ ]
+ }
+ },
"/admin/agencies": {
"get": {
"operationId": "listAgencies",
@@ -276,6 +316,78 @@
]
}
},
+ "/admin/app-config/versions": {
+ "get": {
+ "operationId": "listAppVersions",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "app-version:view",
+ "x-permissions": [
+ "app-version:view"
+ ]
+ },
+ "post": {
+ "operationId": "createAppVersion",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "app-version:create",
+ "x-permissions": [
+ "app-version:create"
+ ]
+ }
+ },
+ "/admin/app-config/versions/{version_id}": {
+ "put": {
+ "operationId": "updateAppVersion",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "parameters": [
+ {
+ "in": "path",
+ "name": "version_id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "x-permission": "app-version:update",
+ "x-permissions": [
+ "app-version:update"
+ ]
+ },
+ "delete": {
+ "operationId": "deleteAppVersion",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "parameters": [
+ {
+ "in": "path",
+ "name": "version_id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "x-permission": "app-version:delete",
+ "x-permissions": [
+ "app-version:delete"
+ ]
+ }
+ },
"/admin/apps": {
"get": {
"operationId": "listApps",
@@ -691,6 +803,130 @@
]
}
},
+ "/admin/game/games": {
+ "get": {
+ "operationId": "listCatalog",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "game:view",
+ "x-permissions": [
+ "game:view"
+ ]
+ },
+ "post": {
+ "operationId": "createCatalog",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "game:create",
+ "x-permissions": [
+ "game:create"
+ ]
+ }
+ },
+ "/admin/game/games/{game_id}": {
+ "patch": {
+ "operationId": "updateCatalog",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "parameters": [
+ {
+ "in": "path",
+ "name": "game_id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "x-permission": "game:update",
+ "x-permissions": [
+ "game:update"
+ ]
+ }
+ },
+ "/admin/game/games/{game_id}/status": {
+ "patch": {
+ "operationId": "setGameStatus",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "parameters": [
+ {
+ "in": "path",
+ "name": "game_id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "x-permission": "game:status",
+ "x-permissions": [
+ "game:status"
+ ]
+ }
+ },
+ "/admin/game/platforms": {
+ "get": {
+ "operationId": "listPlatforms",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "game:view",
+ "x-permissions": [
+ "game:view"
+ ]
+ },
+ "post": {
+ "operationId": "createPlatform",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "game:update",
+ "x-permissions": [
+ "game:update"
+ ]
+ }
+ },
+ "/admin/game/platforms/{platform_code}": {
+ "patch": {
+ "operationId": "updatePlatform",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "parameters": [
+ {
+ "in": "path",
+ "name": "platform_code",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "x-permission": "game:update",
+ "x-permissions": [
+ "game:update"
+ ]
+ }
+ },
"/admin/gifts": {
"get": {
"operationId": "listGifts",
@@ -823,6 +1059,20 @@
]
}
},
+ "/admin/operations/coin-ledger": {
+ "get": {
+ "operationId": "listCoinLedger",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "coin-ledger:view",
+ "x-permissions": [
+ "coin-ledger:view"
+ ]
+ }
+ },
"/admin/payment/recharge-bills": {
"get": {
"operationId": "listRechargeBills",
@@ -837,6 +1087,78 @@
]
}
},
+ "/admin/payment/recharge-products": {
+ "get": {
+ "operationId": "listRechargeProducts",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "payment-product:view",
+ "x-permissions": [
+ "payment-product:view"
+ ]
+ },
+ "post": {
+ "operationId": "createRechargeProduct",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "payment-product:create",
+ "x-permissions": [
+ "payment-product:create"
+ ]
+ }
+ },
+ "/admin/payment/recharge-products/{product_id}": {
+ "put": {
+ "operationId": "updateRechargeProduct",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "parameters": [
+ {
+ "in": "path",
+ "name": "product_id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "x-permission": "payment-product:update",
+ "x-permissions": [
+ "payment-product:update"
+ ]
+ },
+ "delete": {
+ "operationId": "deleteRechargeProduct",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "parameters": [
+ {
+ "in": "path",
+ "name": "product_id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "x-permission": "payment-product:delete",
+ "x-permissions": [
+ "payment-product:delete"
+ ]
+ }
+ },
"/admin/regions": {
"get": {
"operationId": "listRegions",
@@ -1345,6 +1667,32 @@
]
}
},
+ "/admin/rooms/config": {
+ "get": {
+ "operationId": "getRoomConfig",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "room-config:view",
+ "x-permissions": [
+ "room-config:view"
+ ]
+ },
+ "put": {
+ "operationId": "updateRoomConfig",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "room-config:update",
+ "x-permissions": [
+ "room-config:update"
+ ]
+ }
+ },
"/app/users": {
"get": {
"operationId": "appListUsers",
@@ -1471,6 +1819,20 @@
]
}
},
+ "/app/users/bans": {
+ "get": {
+ "operationId": "appListBannedUsers",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "app-user:view",
+ "x-permissions": [
+ "app-user:view"
+ ]
+ }
+ },
"/app/users/login-logs": {
"get": {
"operationId": "appListLoginLogs",
@@ -1709,72 +2071,6 @@
}
}
},
- "/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",
@@ -2623,26 +2919,6 @@
}
}
},
- "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": {
@@ -2991,36 +3267,6 @@
}
]
},
- "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": [
{
@@ -3357,28 +3603,6 @@
],
"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
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
new file mode 100644
index 0000000..2801a31
--- /dev/null
+++ b/deploy/nginx.conf
@@ -0,0 +1,34 @@
+server {
+ listen 80;
+ server_name hyapp-platform.global-interaction.com _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location = /__frontend_healthz {
+ access_log off;
+ add_header Content-Type text/plain;
+ return 200 "ok\n";
+ }
+
+ location /api/ {
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_pass http://127.0.0.1:13100/api/;
+ }
+
+ location = /healthz {
+ proxy_pass http://127.0.0.1:13100/healthz;
+ }
+
+ location = /readyz {
+ proxy_pass http://127.0.0.1:13100/readyz;
+ }
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/src/app/layout/Header.jsx b/src/app/layout/Header.jsx
index 195719f..a566c09 100644
--- a/src/app/layout/Header.jsx
+++ b/src/app/layout/Header.jsx
@@ -4,7 +4,6 @@ import LockReset from "@mui/icons-material/LockReset";
import Logout from "@mui/icons-material/Logout";
import Menu from "@mui/icons-material/Menu";
import MenuOpen from "@mui/icons-material/MenuOpen";
-import NotificationsNoneOutlined from "@mui/icons-material/NotificationsNoneOutlined";
import Person from "@mui/icons-material/Person";
import Refresh from "@mui/icons-material/Refresh";
import Search from "@mui/icons-material/Search";
@@ -15,10 +14,7 @@ import { lazy, Suspense, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
-import { PERMISSIONS } from "@/app/permissions";
import { changePassword } from "@/features/auth/api";
-import { listNotifications } from "@/features/notifications/api";
-import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
@@ -35,19 +31,11 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
const [passwordError, setPasswordError] = useState("");
const [changingPassword, setChangingPassword] = useState(false);
const navigate = useNavigate();
- const { can, logout, user } = useAuth();
+ const { 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) {
@@ -136,10 +124,6 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
}
};
- const openNotifications = () => {
- navigate("/notifications");
- };
-
const refresh = () => {
onRefresh();
showToast("已刷新当前页面", "success");
@@ -191,11 +175,6 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
- {canViewNotifications ? (
-
-
-
- ) : null}
+ ) : null
+ }
+ />
+
+
+ item.id} />
+
+ {items.length} 条
+
+
+
+
+
+
+
+ page.setForm({ platform: event.target.value })}
+ >
+
+
+
+ page.setForm({ version: event.target.value })}
+ />
+ page.setForm({ buildNumber: event.target.value })}
+ />
+ page.setForm({ forceUpdate: checked })}
+ />
+
+
+
+ page.setForm({ downloadUrl: event.target.value })}
+ />
+ page.setForm({ description: event.target.value })}
+ />
+
+
+
+ );
+}
+
+function versionColumns(page) {
+ const columns = [
+ {
+ key: "version",
+ label: "版本",
+ render: (item) => ,
+ filter: createTextColumnFilter({
+ placeholder: "搜索版本、链接、描述",
+ value: page.keyword,
+ onChange: page.setKeyword
+ }),
+ width: "minmax(190px, 0.9fr)"
+ },
+ {
+ key: "platform",
+ label: "平台",
+ render: (item) => platformLabel(item.platform),
+ filter: createOptionsColumnFilter({
+ options: platformOptions,
+ placeholder: "搜索平台",
+ value: page.platform,
+ onChange: page.setPlatform
+ }),
+ width: "minmax(120px, 0.5fr)"
+ },
+ {
+ key: "forceUpdate",
+ label: "强更",
+ render: (item) => ,
+ width: "minmax(110px, 0.45fr)"
+ },
+ {
+ key: "downloadUrl",
+ label: "下载链接",
+ render: (item) => {item.downloadUrl},
+ width: "minmax(280px, 1.25fr)"
+ },
+ {
+ key: "description",
+ label: "更新描述",
+ render: (item) => item.description || "-",
+ width: "minmax(220px, 1fr)"
+ },
+ {
+ key: "updatedAtMs",
+ label: "更新时间",
+ render: (item) => formatMillis(item.updatedAtMs),
+ width: "minmax(160px, 0.7fr)"
+ }
+ ];
+
+ if (!page.abilities.canUpdateVersion && !page.abilities.canDeleteVersion) {
+ return columns;
+ }
+
+ return [
+ ...columns,
+ {
+ key: "actions",
+ label: "操作",
+ render: (item) => ,
+ width: "minmax(96px, 0.42fr)"
+ }
+ ];
+}
+
+function VersionActions({ item, page }) {
+ const deleting = page.loadingAction === `delete:${item.id}`;
+ return (
+
+ {page.abilities.canUpdateVersion ? (
+ page.openEdit(item)}>
+
+
+ ) : null}
+ {page.abilities.canDeleteVersion ? (
+ page.removeVersion(item)}>
+
+
+ ) : null}
+
+ );
+}
+
+function Stack({ primary, secondary }) {
+ return (
+
+ {primary || "-"}
+ {secondary || "-"}
+
+ );
+}
+
+function ForceBadge({ enabled }) {
+ const tone = enabled ? "warning" : "stopped";
+ return (
+
+
+ {enabled ? "强更" : "普通"}
+
+ );
+}
+
+function platformLabel(value) {
+ if (value === "android") {
+ return "Android";
+ }
+ if (value === "ios") {
+ return "iOS";
+ }
+ return value || "-";
+}
diff --git a/src/features/app-config/pages/BannerConfigPage.jsx b/src/features/app-config/pages/BannerConfigPage.jsx
index 015b110..c6335b9 100644
--- a/src/features/app-config/pages/BannerConfigPage.jsx
+++ b/src/features/app-config/pages/BannerConfigPage.jsx
@@ -8,10 +8,14 @@ import { useMemo } from "react";
import { useBannerConfigPage } from "@/features/app-config/hooks/useBannerConfigPage.js";
import styles from "@/features/app-config/app-config.module.css";
import { Button } from "@/shared/ui/Button.jsx";
-import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
+import {
+ AdminFormDialog,
+ AdminFormFieldGrid,
+ AdminFormSection,
+ AdminFormSwitchField,
+} from "@/shared/ui/AdminFormDialog.jsx";
import {
AdminActionIconButton,
- AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
@@ -56,12 +60,6 @@ export function BannerConfigPage() {
) : null
}
- filters={
-
- }
/>
@@ -75,7 +73,7 @@ export function BannerConfigPage() {
- page.setForm({ coverUrl })}
- />
- page.setForm({ bannerType: event.target.value })}
- >
-
-
-
- page.setForm({ param: event.target.value })}
- />
- page.setForm({ status: event.target.value })}
- >
-
-
-
- page.setForm({ platform: event.target.value })}
- >
-
-
-
- page.setForm({ sortOrder: event.target.value })}
- />
- page.setForm({ regionId })}
- />
- page.setForm({ countryCode })}
- />
- page.setForm({ description: event.target.value })}
- />
+
+ page.setForm({ coverUrl })}
+ />
+
+
+
+ page.setForm({ bannerType: event.target.value })}
+ >
+
+
+
+ page.setForm({ platform: event.target.value })}
+ >
+
+
+
+ page.setForm({ param: event.target.value })}
+ />
+
+
+
+
+ page.setForm({ regionId })}
+ />
+ page.setForm({ countryCode })}
+ />
+ page.setForm({ sortOrder: event.target.value })}
+ />
+ page.setForm({ status: checked ? "active" : "disabled" })}
+ />
+
+
+
+ page.setForm({ description: event.target.value })}
+ />
+
);
diff --git a/src/features/app-config/pages/H5ConfigPage.jsx b/src/features/app-config/pages/H5ConfigPage.jsx
index 311ccb1..b9b3bbb 100644
--- a/src/features/app-config/pages/H5ConfigPage.jsx
+++ b/src/features/app-config/pages/H5ConfigPage.jsx
@@ -2,7 +2,7 @@ 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 { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
@@ -74,13 +74,15 @@ export function H5ConfigPage() {
onClose={page.closeEdit}
onSubmit={page.submitEdit}
>
- page.setForm({ url: event.target.value })}
- />
+
+ page.setForm({ url: event.target.value })}
+ />
+
);
diff --git a/src/features/app-config/permissions.js b/src/features/app-config/permissions.js
index c630e1c..8af2af4 100644
--- a/src/features/app-config/permissions.js
+++ b/src/features/app-config/permissions.js
@@ -5,8 +5,12 @@ export function useAppConfigAbilities() {
const { can } = useAuth();
return {
+ canCreateVersion: can(PERMISSIONS.appVersionCreate),
+ canDeleteVersion: can(PERMISSIONS.appVersionDelete),
canUpdate: can(PERMISSIONS.appConfigUpdate),
+ canUpdateVersion: can(PERMISSIONS.appVersionUpdate),
canUpload: can(PERMISSIONS.uploadCreate),
- canView: can(PERMISSIONS.appConfigView)
+ canView: can(PERMISSIONS.appConfigView),
+ canViewVersion: can(PERMISSIONS.appVersionView)
};
}
diff --git a/src/features/app-config/routes.js b/src/features/app-config/routes.js
index 9fbbc2a..0836c7c 100644
--- a/src/features/app-config/routes.js
+++ b/src/features/app-config/routes.js
@@ -16,5 +16,22 @@ export const appConfigRoutes = [
pageKey: "app-config-banners",
path: "/app-config/banners",
permission: PERMISSIONS.appConfigView
+ },
+ {
+ label: "内购配置",
+ loader: () =>
+ import("../payment/pages/RechargeProductConfigPage.jsx").then((module) => module.RechargeProductConfigPage),
+ menuCode: MENU_CODES.paymentRechargeProducts,
+ pageKey: "payment-recharge-products",
+ path: "/app-config/recharge-products",
+ permission: PERMISSIONS.paymentProductView
+ },
+ {
+ label: "版本管理",
+ loader: () => import("./pages/AppVersionManagementPage.jsx").then((module) => module.AppVersionManagementPage),
+ menuCode: MENU_CODES.appConfigVersions,
+ pageKey: "app-config-versions",
+ path: "/app-config/versions",
+ permission: PERMISSIONS.appVersionView
}
];
diff --git a/src/features/app-config/schema.ts b/src/features/app-config/schema.ts
index 7d1fed2..5385c47 100644
--- a/src/features/app-config/schema.ts
+++ b/src/features/app-config/schema.ts
@@ -38,3 +38,15 @@ export const appBannerSchema = z.object({
});
export type AppBannerForm = z.infer;
+
+export const appVersionSchema = z.object({
+ buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"),
+ description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""),
+ downloadUrl: z.string().trim().min(1, "请填写下载链接").max(2048, "下载链接不能超过 2048 个字符")
+ .refine((value) => !/\s/.test(value), "下载链接不能包含空白字符"),
+ forceUpdate: z.boolean(),
+ platform: z.enum(["android", "ios"]),
+ version: z.string().trim().min(1, "请填写版本号").max(40, "版本号不能超过 40 个字符")
+});
+
+export type AppVersionForm = z.infer;
diff --git a/src/features/app-users/api.ts b/src/features/app-users/api.ts
index f2eda7b..2f2c4ce 100644
--- a/src/features/app-users/api.ts
+++ b/src/features/app-users/api.ts
@@ -2,6 +2,7 @@ import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type {
ApiPage,
+ AppUserBanRecordDto,
AppUserLoginLogDto,
AppUserDto,
AppUserPasswordPayload,
@@ -26,6 +27,14 @@ export function listAppUserLoginLogs(query: PageQuery = {}): Promise> {
+ const endpoint = API_ENDPOINTS.appListBannedUsers;
+ return apiRequest>(apiEndpointPath(API_OPERATIONS.appListBannedUsers), {
+ 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 }), {
diff --git a/src/features/app-users/pages/AppUserBanListPage.jsx b/src/features/app-users/pages/AppUserBanListPage.jsx
new file mode 100644
index 0000000..9418512
--- /dev/null
+++ b/src/features/app-users/pages/AppUserBanListPage.jsx
@@ -0,0 +1,194 @@
+import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
+import { useCallback, useMemo, useState } from "react";
+import { DataState } from "@/shared/ui/DataState.jsx";
+import { DataTable } from "@/shared/ui/DataTable.jsx";
+import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
+import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
+import { formatMillis } from "@/shared/utils/time.js";
+import { listAppUserBans } from "@/features/app-users/api";
+import { appUserStatusLabels } from "@/features/app-users/constants.js";
+import styles from "@/features/app-users/app-users.module.css";
+
+const pageSize = 20;
+const emptyData = { items: [], page: 1, pageSize, total: 0 };
+
+export function AppUserBanListPage() {
+ const [targetKeyword, setTargetKeyword] = useState("");
+ const [operatorKeyword, setOperatorKeyword] = useState("");
+ const [page, setPage] = useState(1);
+ const filters = useMemo(
+ () => ({
+ operator_keyword: operatorKeyword,
+ target_keyword: targetKeyword,
+ }),
+ [operatorKeyword, targetKeyword],
+ );
+ const {
+ data = emptyData,
+ error,
+ loading,
+ reload,
+ } = usePaginatedQuery({
+ errorMessage: "加载封禁列表失败",
+ fetcher: listAppUserBans,
+ filters,
+ page,
+ pageSize,
+ queryKey: ["app-users", "bans", filters, page],
+ });
+
+ const changeTargetKeyword = useCallback((value) => {
+ setTargetKeyword(value);
+ setPage(1);
+ }, []);
+
+ const changeOperatorKeyword = useCallback((value) => {
+ setOperatorKeyword(value);
+ setPage(1);
+ }, []);
+
+ const columns = useMemo(
+ () => [
+ {
+ key: "target",
+ label: "封禁用户",
+ width: "minmax(280px, 1.4fr)",
+ filter: createTextColumnFilter({
+ placeholder: "搜索昵称、短 ID、长 ID",
+ value: targetKeyword,
+ onChange: changeTargetKeyword,
+ }),
+ render: (record) => ,
+ },
+ {
+ key: "operator",
+ label: "封禁人",
+ width: "minmax(280px, 1.4fr)",
+ filter: createTextColumnFilter({
+ placeholder: "搜索账号、昵称、短 ID、长 ID",
+ value: operatorKeyword,
+ onChange: changeOperatorKeyword,
+ }),
+ render: (record) => ,
+ },
+ {
+ key: "status",
+ label: "状态",
+ width: "minmax(110px, 0.6fr)",
+ render: (record) => ,
+ },
+ {
+ key: "reason",
+ label: "原因",
+ width: "minmax(190px, 1fr)",
+ render: (record) => {record.reason || "-"},
+ },
+ {
+ key: "createdAtMs",
+ label: "封禁时间",
+ width: "190px",
+ render: (record) => {formatMillis(record.createdAtMs)},
+ },
+ ],
+ [changeOperatorKeyword, changeTargetKeyword, operatorKeyword, targetKeyword],
+ );
+
+ return (
+
+
+
+
+ 0
+ ? {
+ page,
+ pageSize: data.pageSize || pageSize,
+ total: data.total,
+ onPageChange: setPage,
+ }
+ : undefined
+ }
+ rowKey={(record) => record.id}
+ />
+
+
+
+
+ );
+}
+
+function TargetIdentity({ target }) {
+ return (
+
+ );
+}
+
+function OperatorIdentity({ operator }) {
+ if (!operator) {
+ return -;
+ }
+ if (operator.type === "admin") {
+ return (
+
+ );
+ }
+ if (operator.type === "app_user") {
+ return (
+
+ );
+ }
+ return system;
+}
+
+function UserIdentity({ avatar, name, primaryFallback, rows }) {
+ const title = name || primaryFallback || "-";
+ const visibleRows = rows.filter(Boolean);
+ return (
+
+
+ {avatar ?
: }
+
+
+
+ {title}
+
+ {visibleRows.length ? (
+
+ {visibleRows.map((value, index) => (
+
+ {value}
+
+ ))}
+
+ ) : null}
+
+
+ );
+}
+
+function UserStatus({ status }) {
+ return (
+
+
+ {appUserStatusLabels[status] || status || "-"}
+
+ );
+}
diff --git a/src/features/app-users/pages/AppUserListPage.jsx b/src/features/app-users/pages/AppUserListPage.jsx
index c684d6c..21c19be 100644
--- a/src/features/app-users/pages/AppUserListPage.jsx
+++ b/src/features/app-users/pages/AppUserListPage.jsx
@@ -7,12 +7,10 @@ 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 { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
-import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
-import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js";
@@ -91,28 +89,24 @@ export function AppUserListPage() {
return (
-
-
0
+ ? {
+ page: page.page,
+ pageSize: page.data.pageSize || 10,
+ total,
+ onPageChange: page.setPage,
+ }
+ : undefined
+ }
rowKey={(user) => user.userId}
/>
- {total > 0 ? (
-
- ) : null}
@@ -121,6 +115,7 @@ export function AppUserListPage() {
disabled={!page.abilities.canUpdate}
loading={page.loadingAction === "edit"}
open={page.activeAction === "edit"}
+ sectionTitle="用户资料"
title="编辑用户"
onClose={page.closeAction}
onSubmit={page.submitEdit}
@@ -156,6 +151,7 @@ export function AppUserListPage() {
disabled={!page.abilities.canUpdate}
loading={page.loadingAction === "country"}
open={page.activeAction === "country"}
+ sectionTitle="国家信息"
title="修改国家"
onClose={page.closeAction}
onSubmit={page.submitCountry}
@@ -177,6 +173,7 @@ export function AppUserListPage() {
disabled={!page.abilities.canPassword}
loading={page.loadingAction === "password"}
open={page.activeAction === "password"}
+ sectionTitle="密码信息"
title="设置密码"
onClose={page.closeAction}
onSubmit={page.submitPassword}
@@ -280,7 +277,7 @@ function UserStatus({ status }) {
);
}
-function ActionModal({ children, disabled, loading, onClose, onSubmit, open, title }) {
+function ActionModal({ children, disabled, loading, onClose, onSubmit, open, sectionTitle = "基本信息", title }) {
return (
- {children}
+ {children}
);
}
diff --git a/src/features/app-users/pages/AppUserLoginLogsPage.jsx b/src/features/app-users/pages/AppUserLoginLogsPage.jsx
index 5ebb876..122395d 100644
--- a/src/features/app-users/pages/AppUserLoginLogsPage.jsx
+++ b/src/features/app-users/pages/AppUserLoginLogsPage.jsx
@@ -4,9 +4,6 @@ import { useSearchParams } from "react-router-dom";
import { toPageQuery } from "@/shared/api/query";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
-import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
-import { FilterChip } from "@/shared/ui/FilterChip.jsx";
-import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
@@ -68,11 +65,6 @@ export function AppUserLoginLogsPage() {
setPage(1);
}, []);
- const clearUserFilter = useCallback(() => {
- setSearchParams(new URLSearchParams(), { replace: true });
- setPage(1);
- }, [setSearchParams]);
-
const showUserHistory = useCallback(
(log) => {
if (!log.userId) {
@@ -89,13 +81,29 @@ export function AppUserLoginLogsPage() {
[searchParams, setSearchParams],
);
- const resetFilters = () => {
+ const resetIdentityFilter = useCallback(() => {
setQuery("");
- setResult("");
- setRegionId("");
setSearchParams(new URLSearchParams(), { replace: true });
setPage(1);
- };
+ }, [setSearchParams]);
+
+ const changeIdentityFilter = useCallback(
+ (value) => {
+ if (userFilter) {
+ setSearchParams(new URLSearchParams(), { replace: true });
+ }
+ changeQuery(value);
+ },
+ [changeQuery, setSearchParams, userFilter],
+ );
+
+ const selectedUserLabel = useMemo(() => {
+ if (!userFilter) {
+ return "";
+ }
+ const matchedLog = (data.items || []).find((log) => String(log.userId || "") === String(userFilter));
+ return matchedLog?.displayUserId || matchedLog?.username || userFilter;
+ }, [data.items, userFilter]);
const columns = useMemo(
() => [
@@ -104,9 +112,12 @@ export function AppUserLoginLogsPage() {
label: "用户",
width: "minmax(240px, 1.4fr)",
filter: createTextColumnFilter({
+ activeLabel: userFilter ? selectedUserLabel || userFilter : undefined,
+ activeValue: query || userFilter,
+ onReset: resetIdentityFilter,
placeholder: "搜索昵称、短 ID、用户 ID、IP",
value: query,
- onChange: changeQuery,
+ onChange: changeIdentityFilter,
}),
render: (log) => ,
},
@@ -188,31 +199,24 @@ export function AppUserLoginLogsPage() {
render: (log) => {formatMillis(log.createdAtMs)},
},
],
- [changeQuery, changeRegionId, changeResult, loadingRegions, query, regionFilterOptions, regionId, result],
+ [
+ changeIdentityFilter,
+ changeRegionId,
+ changeResult,
+ loadingRegions,
+ query,
+ regionFilterOptions,
+ regionId,
+ resetIdentityFilter,
+ result,
+ selectedUserLabel,
+ userFilter,
+ ],
);
- const selectedUserLabel = useMemo(() => {
- if (!userFilter) {
- return "";
- }
- const matchedLog = (data.items || []).find((log) => String(log.userId || "") === String(userFilter));
- return matchedLog?.displayUserId || matchedLog?.username || userFilter;
- }, [data.items, userFilter]);
return (
-
-
- {userFilter ? (
-
- ) : null}
-
-
-
-
loginLogRowProps(log, showUserHistory)}
rowKey={(log) => log.id}
- title="登录日志"
- total={data.total}
+ pagination={
+ data.total > 0
+ ? {
+ page,
+ pageSize: data.pageSize || pageSize,
+ total: data.total,
+ onPageChange: setPage,
+ }
+ : undefined
+ }
/>
- {data.total > 0 ? (
-
- ) : null}
diff --git a/src/features/app-users/routes.js b/src/features/app-users/routes.js
index e099683..e750f33 100644
--- a/src/features/app-users/routes.js
+++ b/src/features/app-users/routes.js
@@ -17,4 +17,12 @@ export const appUserRoutes = [
path: "/app/users/login-logs",
permission: PERMISSIONS.appUserView,
},
+ {
+ label: "封禁列表",
+ loader: () => import("./pages/AppUserBanListPage.jsx").then((module) => module.AppUserBanListPage),
+ menuCode: MENU_CODES.appUserBans,
+ pageKey: "app-user-bans",
+ path: "/app/users/bans",
+ permission: PERMISSIONS.appUserView,
+ },
];
diff --git a/src/features/daily-tasks/pages/DailyTaskListPage.jsx b/src/features/daily-tasks/pages/DailyTaskListPage.jsx
index fce92f3..342cddb 100644
--- a/src/features/daily-tasks/pages/DailyTaskListPage.jsx
+++ b/src/features/daily-tasks/pages/DailyTaskListPage.jsx
@@ -1,9 +1,14 @@
import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
-import Switch from "@mui/material/Switch";
+import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
-import { AdminFormDialog, AdminFormFieldGrid } from "@/shared/ui/AdminFormDialog.jsx";
+import {
+ AdminFormDialog,
+ AdminFormFieldGrid,
+ AdminFormSection,
+ AdminFormSwitchField,
+} from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
@@ -15,7 +20,6 @@ import {
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
-import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import {
@@ -175,15 +179,22 @@ export function DailyTaskListPage() {
/>
- task.taskId} />
- {total > 0 ? (
-
- ) : null}
+ 0
+ ? {
+ page: page.page,
+ pageSize: page.data.pageSize || 10,
+ total,
+ onPageChange: page.setPage,
+ }
+ : undefined
+ }
+ rowKey={(task) => task.taskId}
+ />
-
-
- page.setForm({ ...form, taskType: event.target.value })}
- >
- {taskTypeOptions.map(([value, label]) => (
-
- ))}
-
- page.setForm({ ...form, status: event.target.value })}
- >
- {taskStatusOptions
- .filter(([value]) => value)
- .map(([value, label]) => (
+
+
+ page.setForm({ ...form, taskType: event.target.value })}
+ >
+ {taskTypeOptions.map(([value, label]) => (
))}
-
- page.setForm({ ...form, title: event.target.value })}
- />
- page.setForm({ ...form, category: event.target.value })}
- />
- page.setForm({ ...form, metricType: event.target.value })}
- />
- page.setForm({ ...form, targetUnit: event.target.value })}
- >
- {taskUnitOptions.map(([value, label]) => (
-
- ))}
-
- page.setForm({ ...form, targetValue: event.target.value })}
- />
- page.setForm({ ...form, rewardCoinAmount: event.target.value })}
- />
- page.setForm({ ...form, sortOrder: event.target.value })}
- />
- page.setForm({ ...form, effectiveFrom: event.target.value })}
- />
- page.setForm({ ...form, effectiveTo: event.target.value })}
- />
+
+
+ page.setForm({ ...form, status: nextTaskStatusFromSwitch(form.status, checked, mode) })
+ }
+ />
+ page.setForm({ ...form, title: event.target.value })}
+ />
+ page.setForm({ ...form, category: event.target.value })}
+ />
+
+
+
+
+ page.setForm({ ...form, metricType: event.target.value })}
+ />
+ page.setForm({ ...form, targetUnit: event.target.value })}
+ >
+ {taskUnitOptions.map(([value, label]) => (
+
+ ))}
+
+ page.setForm({ ...form, targetValue: event.target.value })}
+ />
+ page.setForm({ ...form, rewardCoinAmount: event.target.value })}
+ />
+ page.setForm({ ...form, sortOrder: event.target.value })}
+ />
+
+
+
+
+ page.setForm({ ...form, effectiveFrom: event.target.value })}
+ />
+ page.setForm({ ...form, effectiveTo: event.target.value })}
+ />
+
+
+
page.setForm({ ...form, description: event.target.value })}
/>
-
+
);
}
@@ -380,6 +398,23 @@ function taskStatusLabel(value) {
return taskStatusLabels[value] || value || "-";
}
+function taskSwitchUncheckedLabel(status, mode) {
+ if (status === "archived") {
+ return "归档";
+ }
+ return status === "draft" || mode === "create" ? "草稿" : "暂停";
+}
+
+function nextTaskStatusFromSwitch(status, checked, mode) {
+ if (checked) {
+ return "active";
+ }
+ if (status === "archived") {
+ return "archived";
+ }
+ return status === "draft" || mode === "create" ? "draft" : "paused";
+}
+
function taskUnitLabel(value) {
return taskUnitLabels[value] || value || "";
}
diff --git a/src/features/dashboard/components/DashboardAlerts.jsx b/src/features/dashboard/components/DashboardAlerts.jsx
deleted file mode 100644
index 6c14b4e..0000000
--- a/src/features/dashboard/components/DashboardAlerts.jsx
+++ /dev/null
@@ -1,16 +0,0 @@
-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
index 2d72a0f..ee9fcb5 100644
--- a/src/features/dashboard/components/DashboardCharts.jsx
+++ b/src/features/dashboard/components/DashboardCharts.jsx
@@ -1,5 +1,3 @@
-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 }) {
@@ -7,12 +5,6 @@ export function DashboardCharts({ overview }) {
);
}
diff --git a/src/features/dashboard/components/DashboardStats.jsx b/src/features/dashboard/components/DashboardStats.jsx
index 816c967..1299703 100644
--- a/src/features/dashboard/components/DashboardStats.jsx
+++ b/src/features/dashboard/components/DashboardStats.jsx
@@ -1,6 +1,5 @@
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";
@@ -10,7 +9,6 @@ export function DashboardStats({ overview, stats }) {
启用 {stats.activeUsers}} />
- 锁定 {stats.lockedUsers} / 停用 {stats.disabledUsers}>} />
);
}
diff --git a/src/features/dashboard/hooks/useDashboardPage.js b/src/features/dashboard/hooks/useDashboardPage.js
index 2ce6aa7..8bd6fcf 100644
--- a/src/features/dashboard/hooks/useDashboardPage.js
+++ b/src/features/dashboard/hooks/useDashboardPage.js
@@ -23,7 +23,7 @@ export function useDashboardPage() {
const stats = useMemo(() => {
if (!overview) {
- return { activeUsers: 0, disabledUsers: 0, lockedUsers: 0, logsToday: 0, menuTotal: 0, rolesTotal: 0, unreadNotifications: 0, usersTotal: 0 };
+ return { activeUsers: 0, disabledUsers: 0, lockedUsers: 0, logsToday: 0, menuTotal: 0, rolesTotal: 0, usersTotal: 0 };
}
return {
activeUsers: overview.activeUsers,
@@ -32,7 +32,6 @@ export function useDashboardPage() {
logsToday: overview.logsToday,
menuTotal: overview.menuTotal,
rolesTotal: overview.rolesTotal,
- unreadNotifications: overview.unreadNotifications,
usersTotal: overview.usersTotal
};
}, [overview]);
diff --git a/src/features/dashboard/pages/OverviewPage.jsx b/src/features/dashboard/pages/OverviewPage.jsx
index 0ace248..17668f7 100644
--- a/src/features/dashboard/pages/OverviewPage.jsx
+++ b/src/features/dashboard/pages/OverviewPage.jsx
@@ -1,5 +1,4 @@
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";
@@ -20,8 +19,6 @@ export function OverviewPage() {
-
-
>
);
diff --git a/src/features/games/api.ts b/src/features/games/api.ts
new file mode 100644
index 0000000..4bcdaa9
--- /dev/null
+++ b/src/features/games/api.ts
@@ -0,0 +1,155 @@
+import { apiRequest } from "@/shared/api/request";
+import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
+import type { QueryParams } from "@/shared/api/request";
+
+export interface GamePlatformDto {
+ appCode?: string;
+ platformCode: string;
+ platformName: string;
+ status: string;
+ apiBaseUrl: string;
+ sortOrder: number;
+ createdAtMs?: number;
+ updatedAtMs?: number;
+}
+
+export interface GameCatalogDto {
+ appCode?: string;
+ gameId: string;
+ platformCode: string;
+ providerGameId: string;
+ gameName: string;
+ category: string;
+ iconUrl: string;
+ coverUrl: string;
+ launchMode: string;
+ orientation: string;
+ minCoin: number;
+ status: string;
+ sortOrder: number;
+ tags: string[];
+ createdAtMs?: number;
+ updatedAtMs?: number;
+}
+
+export interface GameCatalogPage {
+ items: GameCatalogDto[];
+ nextCursor?: string;
+ pageSize?: number;
+ serverTimeMs?: number;
+}
+
+export interface GamePlatformPage {
+ items: GamePlatformDto[];
+ serverTimeMs?: number;
+}
+
+export type GamePlatformPayload = Omit;
+export type GameCatalogPayload = Omit;
+
+export interface GameCatalogQuery {
+ platformCode?: string;
+ status?: string;
+ pageSize?: number;
+ cursor?: string;
+}
+
+export function listGamePlatforms(status = ""): Promise {
+ const endpoint = API_ENDPOINTS.listPlatforms;
+ return apiRequest(apiEndpointPath(API_OPERATIONS.listPlatforms), {
+ method: endpoint.method,
+ query: { status },
+ }).then((page) => ({
+ ...page,
+ items: (page.items || []).map(normalizePlatform),
+ }));
+}
+
+export function upsertGamePlatform(payload: GamePlatformPayload, platformCode = ""): Promise {
+ const operation = platformCode ? API_OPERATIONS.updatePlatform : API_OPERATIONS.createPlatform;
+ const endpoint = API_ENDPOINTS[operation];
+ return apiRequest(
+ apiEndpointPath(operation, platformCode ? { platform_code: platformCode } : {}),
+ {
+ body: payload,
+ method: endpoint.method,
+ },
+ ).then(normalizePlatform);
+}
+
+export function listGameCatalog(query: GameCatalogQuery = {}): Promise {
+ const endpoint = API_ENDPOINTS.listCatalog;
+ return apiRequest(apiEndpointPath(API_OPERATIONS.listCatalog), {
+ method: endpoint.method,
+ query: query as QueryParams,
+ }).then((page) => ({
+ ...page,
+ items: (page.items || []).map(normalizeCatalog),
+ }));
+}
+
+export function upsertGameCatalog(payload: GameCatalogPayload, gameId = ""): Promise {
+ const operation = gameId ? API_OPERATIONS.updateCatalog : API_OPERATIONS.createCatalog;
+ const endpoint = API_ENDPOINTS[operation];
+ return apiRequest(
+ apiEndpointPath(operation, gameId ? { game_id: gameId } : {}),
+ {
+ body: payload,
+ method: endpoint.method,
+ },
+ ).then(normalizeCatalog);
+}
+
+export function setGameStatus(gameId: string, status: string): Promise {
+ const endpoint = API_ENDPOINTS.setGameStatus;
+ return apiRequest(
+ apiEndpointPath(API_OPERATIONS.setGameStatus, { game_id: gameId }),
+ {
+ body: { status },
+ method: endpoint.method,
+ },
+ ).then(normalizeCatalog);
+}
+
+function normalizePlatform(platform: Partial): GamePlatformDto {
+ return {
+ appCode: stringValue(platform.appCode),
+ platformCode: stringValue(platform.platformCode),
+ platformName: stringValue(platform.platformName),
+ status: stringValue(platform.status || "active"),
+ apiBaseUrl: stringValue(platform.apiBaseUrl),
+ sortOrder: numberValue(platform.sortOrder),
+ createdAtMs: numberValue(platform.createdAtMs),
+ updatedAtMs: numberValue(platform.updatedAtMs),
+ };
+}
+
+function normalizeCatalog(game: Partial): GameCatalogDto {
+ return {
+ appCode: stringValue(game.appCode),
+ gameId: stringValue(game.gameId),
+ platformCode: stringValue(game.platformCode),
+ providerGameId: stringValue(game.providerGameId),
+ gameName: stringValue(game.gameName),
+ category: stringValue(game.category),
+ iconUrl: stringValue(game.iconUrl),
+ coverUrl: stringValue(game.coverUrl),
+ launchMode: stringValue(game.launchMode || "h5_popup"),
+ orientation: stringValue(game.orientation || "portrait"),
+ minCoin: numberValue(game.minCoin),
+ status: stringValue(game.status || "active"),
+ sortOrder: numberValue(game.sortOrder),
+ tags: Array.isArray(game.tags) ? game.tags.map(String).filter(Boolean) : [],
+ createdAtMs: numberValue(game.createdAtMs),
+ updatedAtMs: numberValue(game.updatedAtMs),
+ };
+}
+
+function stringValue(value: unknown) {
+ return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
+}
+
+function numberValue(value: unknown) {
+ const number = Number(value || 0);
+ return Number.isFinite(number) ? number : 0;
+}
diff --git a/src/features/games/games.module.css b/src/features/games/games.module.css
new file mode 100644
index 0000000..b6bce6c
--- /dev/null
+++ b/src/features/games/games.module.css
@@ -0,0 +1,57 @@
+.identity {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+
+.icon {
+ width: 44px;
+ height: 44px;
+ flex: 0 0 auto;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg-card-strong);
+ object-fit: cover;
+}
+
+.identityText,
+.stack {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.name {
+ overflow: hidden;
+ color: var(--text-primary);
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.meta,
+.tags {
+ overflow: hidden;
+ color: var(--text-tertiary);
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tags {
+ color: var(--text-secondary);
+}
+
+.switchOnlyField {
+ display: inline-flex;
+ min-height: 56px;
+ align-items: center;
+ justify-content: flex-start;
+ width: 100%;
+}
+
+.fullField {
+ grid-column: 1 / -1;
+}
diff --git a/src/features/games/hooks/useGamesPage.js b/src/features/games/hooks/useGamesPage.js
new file mode 100644
index 0000000..c86192c
--- /dev/null
+++ b/src/features/games/hooks/useGamesPage.js
@@ -0,0 +1,303 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ listGameCatalog,
+ listGamePlatforms,
+ setGameStatus,
+ upsertGameCatalog,
+ upsertGamePlatform,
+} from "@/features/games/api";
+import { useGameAbilities } from "@/features/games/permissions.js";
+import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
+import { useToast } from "@/shared/ui/ToastProvider.jsx";
+
+const defaultGameForm = {
+ gameId: "",
+ platformCode: "",
+ providerGameId: "",
+ gameName: "",
+ category: "casual",
+ iconUrl: "",
+ coverUrl: "",
+ launchMode: "h5_popup",
+ orientation: "portrait",
+ minCoin: 0,
+ status: "active",
+ sortOrder: 0,
+ tagsText: "",
+};
+
+const defaultPlatformForm = {
+ platformCode: "",
+ platformName: "",
+ status: "active",
+ apiBaseUrl: "",
+ sortOrder: 0,
+};
+
+const emptyCatalog = { items: [], pageSize: 50 };
+const emptyPlatforms = { items: [] };
+const defaultPageSize = 50;
+
+export function useGamesPage() {
+ const abilities = useGameAbilities();
+ const { showToast } = useToast();
+ const [platformCode, setPlatformCode] = useState("");
+ const [status, setStatus] = useState("");
+ const [page, setPage] = useState(1);
+ const [pageCursors, setPageCursors] = useState({ 1: "" });
+ const [pageSize, setPageSize] = useState(defaultPageSize);
+ const [activeAction, setActiveAction] = useState("");
+ const [editingGameId, setEditingGameId] = useState("");
+ const [editingPlatformCode, setEditingPlatformCode] = useState("");
+ const [gameForm, setGameForm] = useState(defaultGameForm);
+ const [platformForm, setPlatformForm] = useState(defaultPlatformForm);
+ const [loadingAction, setLoadingAction] = useState("");
+
+ const platformsQueryFn = useCallback(() => listGamePlatforms(), []);
+ const {
+ data: platformData = emptyPlatforms,
+ error: platformError,
+ loading: platformsLoading,
+ reload: reloadPlatforms,
+ } = useAdminQuery(platformsQueryFn, {
+ errorMessage: "加载游戏平台失败",
+ initialData: emptyPlatforms,
+ queryKey: ["games", "platforms"],
+ });
+
+ const catalogQueryFn = useCallback(
+ () => listGameCatalog({ cursor: pageCursors[page] || "", platformCode, status, pageSize }),
+ [page, pageCursors, pageSize, platformCode, status],
+ );
+ const {
+ data = emptyCatalog,
+ error,
+ loading,
+ reload,
+ } = useAdminQuery(catalogQueryFn, {
+ errorMessage: "加载游戏列表失败",
+ initialData: emptyCatalog,
+ keepPreviousData: true,
+ queryKey: ["games", "catalog", platformCode, status, pageSize, page, pageCursors[page] || ""],
+ });
+
+ const platformOptions = useMemo(
+ () => (platformData.items || []).map((platform) => [platform.platformCode, platform.platformName]),
+ [platformData.items],
+ );
+
+ useEffect(() => {
+ if (!gameForm.platformCode && platformOptions.length > 0 && activeAction === "create-game") {
+ setGameForm((current) => ({ ...current, platformCode: platformOptions[0][0] }));
+ }
+ }, [activeAction, gameForm.platformCode, platformOptions]);
+
+ const resetCatalogPage = () => {
+ setPage(1);
+ setPageCursors({ 1: "" });
+ };
+
+ const changePlatformCode = (value) => {
+ setPlatformCode(value);
+ resetCatalogPage();
+ };
+
+ const changeStatus = (value) => {
+ setStatus(value);
+ resetCatalogPage();
+ };
+
+ const changePageSize = (value) => {
+ setPageSize(value);
+ resetCatalogPage();
+ };
+
+ const changePage = (nextPage) => {
+ if (nextPage < 1 || nextPage === page) {
+ return;
+ }
+ if (nextPage === page + 1) {
+ if (!data.nextCursor) {
+ return;
+ }
+ setPageCursors((current) => ({ ...current, [nextPage]: data.nextCursor }));
+ }
+ setPage(nextPage);
+ };
+
+ const resetFilters = () => {
+ setPlatformCode("");
+ setStatus("");
+ setPageSize(defaultPageSize);
+ resetCatalogPage();
+ };
+
+ const openCreateGame = () => {
+ setEditingGameId("");
+ setGameForm({ ...defaultGameForm, platformCode: platformOptions[0]?.[0] || "" });
+ setActiveAction("create-game");
+ };
+
+ const openEditGame = (game) => {
+ setEditingGameId(game.gameId);
+ setGameForm(gameToForm(game));
+ setActiveAction("edit-game");
+ };
+
+ const openCreatePlatform = () => {
+ setEditingPlatformCode("");
+ setPlatformForm(defaultPlatformForm);
+ setActiveAction("create-platform");
+ };
+
+ const openEditPlatform = (platform) => {
+ setEditingPlatformCode(platform.platformCode);
+ setPlatformForm(platformToForm(platform));
+ setActiveAction("edit-platform");
+ };
+
+ const closeAction = () => {
+ setActiveAction("");
+ setEditingGameId("");
+ setEditingPlatformCode("");
+ };
+
+ const submitGame = async (event) => {
+ event.preventDefault();
+ const payload = gamePayload(gameForm);
+ setLoadingAction(activeAction);
+ try {
+ await upsertGameCatalog(payload, editingGameId);
+ await reload();
+ showToast(editingGameId ? "游戏已更新" : "游戏已创建", "success");
+ closeAction();
+ } catch (err) {
+ showToast(err.message || "保存游戏失败", "error");
+ } finally {
+ setLoadingAction("");
+ }
+ };
+
+ const submitPlatform = async (event) => {
+ event.preventDefault();
+ const payload = platformPayload(platformForm);
+ setLoadingAction(activeAction);
+ try {
+ await upsertGamePlatform(payload, editingPlatformCode);
+ await reloadPlatforms();
+ await reload();
+ showToast(editingPlatformCode ? "平台已更新" : "平台已创建", "success");
+ closeAction();
+ } catch (err) {
+ showToast(err.message || "保存平台失败", "error");
+ } finally {
+ setLoadingAction("");
+ }
+ };
+
+ const changeGameStatus = async (game, checked) => {
+ const nextStatus = checked ? "active" : "maintenance";
+ setLoadingAction(`status-${game.gameId}`);
+ try {
+ await setGameStatus(game.gameId, nextStatus);
+ await reload();
+ showToast("游戏状态已更新", "success");
+ } catch (err) {
+ showToast(err.message || "状态更新失败", "error");
+ } finally {
+ setLoadingAction("");
+ }
+ };
+
+ return {
+ abilities,
+ activeAction,
+ changeGameStatus,
+ closeAction,
+ data,
+ error: error || platformError,
+ gameForm,
+ loading: loading || platformsLoading,
+ loadingAction,
+ page,
+ openCreateGame,
+ openCreatePlatform,
+ openEditGame,
+ openEditPlatform,
+ pageSize,
+ platformCode,
+ platformData,
+ platformForm,
+ platformOptions,
+ reload,
+ resetFilters,
+ changePage,
+ changePageSize,
+ setGameForm,
+ setPlatformCode: changePlatformCode,
+ setPlatformForm,
+ setStatus: changeStatus,
+ status,
+ submitGame,
+ submitPlatform,
+ };
+}
+
+function gameToForm(game) {
+ return {
+ gameId: game.gameId || "",
+ platformCode: game.platformCode || "",
+ providerGameId: game.providerGameId || "",
+ gameName: game.gameName || "",
+ category: game.category || "casual",
+ iconUrl: game.iconUrl || "",
+ coverUrl: game.coverUrl || "",
+ launchMode: game.launchMode || "h5_popup",
+ orientation: game.orientation || "portrait",
+ minCoin: Number(game.minCoin || 0),
+ status: game.status || "active",
+ sortOrder: Number(game.sortOrder || 0),
+ tagsText: (game.tags || []).join(", "),
+ };
+}
+
+function platformToForm(platform) {
+ return {
+ platformCode: platform.platformCode || "",
+ platformName: platform.platformName || "",
+ status: platform.status || "active",
+ apiBaseUrl: platform.apiBaseUrl || "",
+ sortOrder: Number(platform.sortOrder || 0),
+ };
+}
+
+function gamePayload(form) {
+ return {
+ gameId: form.gameId.trim(),
+ platformCode: form.platformCode.trim(),
+ providerGameId: form.providerGameId.trim(),
+ gameName: form.gameName.trim(),
+ category: form.category.trim(),
+ iconUrl: form.iconUrl.trim(),
+ coverUrl: form.coverUrl.trim(),
+ launchMode: form.launchMode.trim() || "h5_popup",
+ orientation: form.orientation.trim() || "portrait",
+ minCoin: Number(form.minCoin || 0),
+ status: form.status.trim() || "active",
+ sortOrder: Number(form.sortOrder || 0),
+ tags: form.tagsText
+ .split(",")
+ .map((tag) => tag.trim())
+ .filter(Boolean),
+ };
+}
+
+function platformPayload(form) {
+ return {
+ platformCode: form.platformCode.trim(),
+ platformName: form.platformName.trim(),
+ status: form.status.trim() || "active",
+ apiBaseUrl: form.apiBaseUrl.trim(),
+ sortOrder: Number(form.sortOrder || 0),
+ };
+}
diff --git a/src/features/games/pages/GameListPage.jsx b/src/features/games/pages/GameListPage.jsx
new file mode 100644
index 0000000..8159c52
--- /dev/null
+++ b/src/features/games/pages/GameListPage.jsx
@@ -0,0 +1,440 @@
+import Add from "@mui/icons-material/Add";
+import EditOutlined from "@mui/icons-material/EditOutlined";
+import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
+import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
+import MenuItem from "@mui/material/MenuItem";
+import TextField from "@mui/material/TextField";
+import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
+import { DataState } from "@/shared/ui/DataState.jsx";
+import { DataTable } from "@/shared/ui/DataTable.jsx";
+import {
+ AdminActionIconButton,
+ AdminListBody,
+ AdminListPage,
+ AdminListToolbar,
+ AdminRowActions,
+} from "@/shared/ui/AdminListLayout.jsx";
+import { createOptionsColumnFilter } from "@/shared/ui/tableFilters.js";
+import { UploadField } from "@/shared/ui/UploadField.jsx";
+import { formatMillis } from "@/shared/utils/time.js";
+import { useGamesPage } from "@/features/games/hooks/useGamesPage.js";
+import styles from "@/features/games/games.module.css";
+
+const statusOptions = [
+ ["", "全部状态"],
+ ["active", "Active"],
+ ["maintenance", "Maintenance"],
+ ["disabled", "Disabled"],
+];
+
+const orientationOptions = [
+ ["portrait", "Portrait"],
+ ["landscape", "Landscape"],
+];
+const launchModeOptions = [["h5_popup", "H5 弹窗"]];
+
+const baseColumns = [
+ {
+ key: "game",
+ label: "游戏",
+ width: "minmax(290px, 1.5fr)",
+ render: (game) => ,
+ },
+ {
+ key: "platform",
+ label: "平台",
+ width: "minmax(160px, 0.75fr)",
+ render: (game) => (
+
+ {game.platformCode}
+ {game.providerGameId}
+
+ ),
+ },
+ {
+ key: "launch",
+ label: "启动",
+ width: "minmax(150px, 0.75fr)",
+ render: (game) => (
+
+ {game.launchMode}
+ {game.orientation}
+
+ ),
+ },
+ {
+ key: "coin",
+ label: "最低金币",
+ width: "minmax(116px, 0.55fr)",
+ render: (game) => formatNumber(game.minCoin),
+ },
+ {
+ key: "status",
+ label: "状态",
+ width: "minmax(120px, 0.6fr)",
+ },
+ {
+ key: "updatedAt",
+ label: "更新时间",
+ width: "minmax(170px, 0.9fr)",
+ render: (game) => formatMillis(game.updatedAtMs || game.createdAtMs),
+ },
+ {
+ key: "actions",
+ label: "操作",
+ width: "minmax(76px, 0.4fr)",
+ },
+];
+
+export function GameListPage() {
+ const page = useGamesPage();
+ const items = page.data.items || [];
+ const platformFilterOptions = [["", "全部平台"], ...page.platformOptions];
+ const columns = baseColumns.map((column) => {
+ if (column.key === "platform") {
+ return {
+ ...column,
+ filter: createOptionsColumnFilter({
+ options: platformFilterOptions,
+ placeholder: "搜索平台",
+ value: page.platformCode,
+ onChange: page.setPlatformCode,
+ }),
+ };
+ }
+ if (column.key === "status") {
+ return {
+ ...column,
+ filter: createOptionsColumnFilter({
+ options: statusOptions,
+ placeholder: "搜索状态",
+ value: page.status,
+ onChange: page.setStatus,
+ }),
+ render: (game) => ,
+ };
+ }
+ if (column.key === "actions") {
+ return {
+ ...column,
+ render: (game) => ,
+ };
+ }
+ return column;
+ });
+
+ return (
+
+
+ {page.abilities.canUpdate ? (
+
+
+
+ ) : null}
+ {page.abilities.canCreate ? (
+
+
+
+ ) : null}
+ >
+ }
+ />
+
+
+ game.gameId}
+ />
+
+
+
+
+
+ );
+}
+
+function GameIdentity({ game }) {
+ return (
+
+ {game.iconUrl ? (
+

+ ) : (
+
+ )}
+
+ {game.gameName || game.gameId}
+ {game.gameId}
+ {(game.tags || []).slice(0, 3).join(" / ")}
+
+
+ );
+}
+
+function GameStatusSwitch({ page, game }) {
+ const disabled =
+ !page.abilities.canStatus || game.status === "disabled" || page.loadingAction === `status-${game.gameId}`;
+ return (
+ page.changeGameStatus(game, event.target.checked)}
+ />
+ );
+}
+
+function GameRowActions({ page, game }) {
+ return (
+
+ page.openEditGame(game)}
+ >
+
+
+
+ );
+}
+
+function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
+ const disabled = mode === "edit" ? !page.abilities.canUpdate : !page.abilities.canCreate;
+ return (
+
+
+
+ page.setGameForm({ ...form, gameId: event.target.value })}
+ />
+ 0}
+ value={form.platformCode}
+ onChange={(event) => page.setGameForm({ ...form, platformCode: event.target.value })}
+ >
+ {page.platformOptions.map(([value, label]) => (
+
+ ))}
+
+ page.setGameForm({ ...form, providerGameId: event.target.value })}
+ />
+ page.setGameForm({ ...form, gameName: event.target.value })}
+ />
+ page.setGameForm({ ...form, category: event.target.value })}
+ />
+
+
+
+
+ page.setGameForm({ ...form, launchMode: event.target.value })}
+ >
+ {launchModeOptions.map(([value, label]) => (
+
+ ))}
+
+ page.setGameForm({ ...form, orientation: event.target.value })}
+ >
+ {orientationOptions.map(([value, label]) => (
+
+ ))}
+
+ page.setGameForm({ ...form, minCoin: event.target.value })}
+ />
+ page.setGameForm({ ...form, sortOrder: event.target.value })}
+ />
+
+ page.setGameForm({
+ ...form,
+ status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
+ })
+ }
+ />
+
+
+
+
+ page.setGameForm({ ...form, iconUrl })}
+ />
+ page.setGameForm({ ...form, coverUrl })}
+ />
+ page.setGameForm({ ...form, tagsText: event.target.value })}
+ />
+
+
+
+ );
+}
+
+function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
+ return (
+
+
+
+ page.setPlatformForm({ ...form, platformCode: event.target.value })}
+ />
+ page.setPlatformForm({ ...form, platformName: event.target.value })}
+ />
+
+ page.setPlatformForm({
+ ...form,
+ status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
+ })
+ }
+ />
+ page.setPlatformForm({ ...form, sortOrder: event.target.value })}
+ />
+
+
+
+ page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })}
+ />
+
+
+ );
+}
+
+function SwitchOnlyField({ checked, checkedLabel, disabled, label, onChange, uncheckedLabel }) {
+ return (
+
+ onChange(event.target.checked, event)}
+ />
+
+ );
+}
+
+function formatNumber(value) {
+ return Number(value || 0).toLocaleString();
+}
diff --git a/src/features/games/permissions.js b/src/features/games/permissions.js
new file mode 100644
index 0000000..f788c97
--- /dev/null
+++ b/src/features/games/permissions.js
@@ -0,0 +1,14 @@
+import { useAuth } from "@/app/auth/AuthProvider.jsx";
+import { PERMISSIONS } from "@/app/permissions";
+
+export function useGameAbilities() {
+ const { can } = useAuth();
+
+ return {
+ canCreate: can(PERMISSIONS.gameCreate),
+ canStatus: can(PERMISSIONS.gameStatus),
+ canUpdate: can(PERMISSIONS.gameUpdate),
+ canUpload: can(PERMISSIONS.uploadCreate),
+ canView: can(PERMISSIONS.gameView),
+ };
+}
diff --git a/src/features/games/routes.js b/src/features/games/routes.js
new file mode 100644
index 0000000..2ef5d87
--- /dev/null
+++ b/src/features/games/routes.js
@@ -0,0 +1,12 @@
+import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
+
+export const gameRoutes = [
+ {
+ label: "游戏列表",
+ loader: () => import("./pages/GameListPage.jsx").then((module) => module.GameListPage),
+ menuCode: MENU_CODES.gameList,
+ pageKey: "game-list",
+ path: "/games",
+ permission: PERMISSIONS.gameView,
+ },
+];
diff --git a/src/features/host-org/components/HostOrgActionModal.jsx b/src/features/host-org/components/HostOrgActionModal.jsx
index 4f89886..b6ea470 100644
--- a/src/features/host-org/components/HostOrgActionModal.jsx
+++ b/src/features/host-org/components/HostOrgActionModal.jsx
@@ -1,4 +1,4 @@
-import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
+import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
export function HostOrgActionModal({
children,
@@ -7,6 +7,8 @@ export function HostOrgActionModal({
onClose,
onSubmit,
open,
+ sectioned = true,
+ sectionTitle = "表单信息",
size = "compact",
submitLabel = "提交",
submitVariant = "primary",
@@ -24,7 +26,7 @@ export function HostOrgActionModal({
onClose={onClose}
onSubmit={onSubmit}
>
- {children}
+ {sectioned ? {children} : children}
);
}
diff --git a/src/features/host-org/components/HostOrgFilters.jsx b/src/features/host-org/components/HostOrgFilters.jsx
deleted file mode 100644
index 21720a4..0000000
--- a/src/features/host-org/components/HostOrgFilters.jsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
-import styles from "@/features/host-org/host-org.module.css";
-
-export function HostOrgFilters({
- extraFilters = [],
- onReset,
- query,
- regionId,
- status,
-}) {
- const hasActiveFilters = [query, regionId, status, ...extraFilters.map((filter) => filter.value)].some(
- (value) => String(value || "").trim() !== "",
- );
-
- return (
-
- );
-}
diff --git a/src/features/host-org/components/HostOrgToolbar.jsx b/src/features/host-org/components/HostOrgToolbar.jsx
index 946238e..a9d5846 100644
--- a/src/features/host-org/components/HostOrgToolbar.jsx
+++ b/src/features/host-org/components/HostOrgToolbar.jsx
@@ -1,35 +1,32 @@
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 }) {
+export function HostOrgToolbar({ actions = [] }) {
+ if (!actions.length) {
+ return null;
+ }
+
return (
-
-
+
+ {actions.map((action) => (
+
+
+
+ {action.icon}
+
+
+
+ ))}
-
- {actions.length ? (
-
- {actions.map((action) => (
-
-
-
- {action.icon}
-
-
-
- ))}
-
- ) : null}
);
}
diff --git a/src/features/host-org/hooks/useHostAgenciesPage.js b/src/features/host-org/hooks/useHostAgenciesPage.js
index ccbb057..3bfc9c8 100644
--- a/src/features/host-org/hooks/useHostAgenciesPage.js
+++ b/src/features/host-org/hooks/useHostAgenciesPage.js
@@ -116,6 +116,19 @@ export function useHostAgenciesPage() {
});
};
+ const closeAgencyFromList = async (agency) => {
+ if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") {
+ return;
+ }
+ await runAction(`agency-status-${agency.agencyId}`, "Agency 已关闭", async () => {
+ await closeAgency(agency.agencyId, {
+ commandId: makeCommandId("agency-close"),
+ reason: "close agency from list switch",
+ });
+ await reload();
+ });
+ };
+
const submitJoinEnabled = async (event) => {
event.preventDefault();
await runAction("agency-join", "入会开关已更新", async () => {
@@ -149,6 +162,7 @@ export function useHostAgenciesPage() {
changeQuery,
changeRegionId,
changeStatus,
+ closeAgencyFromList,
closeForm,
data,
error,
diff --git a/src/features/host-org/hooks/useHostBdsPage.js b/src/features/host-org/hooks/useHostBdsPage.js
index 0194759..54fca9e 100644
--- a/src/features/host-org/hooks/useHostBdsPage.js
+++ b/src/features/host-org/hooks/useHostBdsPage.js
@@ -15,17 +15,10 @@ import {
import { listAppUsers } from "@/features/app-users/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";
+import { 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 };
const emptyLookup = () => ({ error: "", loading: false, user: null });
@@ -42,7 +35,6 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
const [page, setPage] = useState(1);
const [bdLeaderForm, setBDLeaderForm] = useState(emptyBDLeaderForm);
const [bdForm, setBDForm] = useState(emptyBDForm);
- const [bdStatusForm, setBDStatusForm] = useState(() => emptyBDStatusForm(profileType));
const [bdUserLookup, setBDUserLookup] = useState(emptyLookup);
const [leaderBDs, setLeaderBDs] = useState(emptyData);
const [leaderBDsError, setLeaderBDsError] = useState("");
@@ -194,22 +186,6 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
});
};
- 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 toggleBDLeader = async (leader, enabled) => {
const status = enabled ? "active" : "disabled";
await runAction(
@@ -227,6 +203,19 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
);
};
+ const toggleBD = async (bd, enabled) => {
+ const status = enabled ? "active" : "disabled";
+ await runAction(`bd-status-${bd.userId}`, enabled ? "BD 已启用" : "BD 已停用", async () => {
+ const data = await setBDStatus(bd.userId, {
+ commandId: makeCommandId("bd-status"),
+ reason: "",
+ status,
+ });
+ await reload();
+ return data;
+ });
+ };
+
const removeBDLeader = async (leader) => {
const ok = await confirm({
confirmText: "删除",
@@ -284,7 +273,6 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
activeAction,
bdForm,
bdLeaderForm,
- bdStatusForm,
bdUserLookup,
changeParentLeaderUserId,
changeQuery,
@@ -312,18 +300,16 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
openAddLeaderBD,
openBDForm: () => setActiveAction("bd"),
openBDLeaderForm: () => setActiveAction("bd-leader"),
- openBDStatusForm: () => setActiveAction("bd-status"),
openLeaderBDs,
selectedLeader,
setBDForm,
setBDLeaderForm,
- setBDStatusForm,
setPage,
status,
submitBD,
submitBDLeader,
submitLeaderBD,
- submitBDStatus,
+ toggleBD,
toggleBDLeader,
};
}
diff --git a/src/features/host-org/hooks/useHostManagersPage.js b/src/features/host-org/hooks/useHostManagersPage.js
new file mode 100644
index 0000000..3c09c0f
--- /dev/null
+++ b/src/features/host-org/hooks/useHostManagersPage.js
@@ -0,0 +1,123 @@
+import { useMemo, useState } from "react";
+import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
+import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
+import { useToast } from "@/shared/ui/ToastProvider.jsx";
+import { closeAgency, listAgencies } from "@/features/host-org/api";
+import { useAgencyAbilities } from "@/features/host-org/permissions.js";
+
+const pageSize = 10;
+const emptyData = { items: [], page: 1, pageSize, total: 0 };
+
+export function useHostManagersPage() {
+ 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 [loadingAction, setLoadingAction] = 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: "加载经理列表失败",
+ fetcher: listAgencies,
+ filters,
+ page,
+ pageSize,
+ queryKey: ["host-org", "managers", 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 resetFilters = () => {
+ setQuery("");
+ setStatus("");
+ setRegionId("");
+ setParentBdUserId("");
+ setPage(1);
+ };
+
+ const closeManagerAgency = async (agency) => {
+ if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") {
+ return;
+ }
+ await runAction(`manager-status-${agency.agencyId}`, "经理已停用", async () => {
+ await closeAgency(agency.agencyId, {
+ commandId: makeCommandId("manager-close"),
+ reason: "close manager agency from list switch",
+ });
+ 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,
+ changeParentBdUserId,
+ changeQuery,
+ changeRegionId,
+ changeStatus,
+ closeManagerAgency,
+ data,
+ error,
+ loading,
+ loadingAction,
+ loadingRegions,
+ page,
+ parentBdUserId,
+ query,
+ regionId,
+ regionOptions,
+ reload,
+ resetFilters,
+ setPage,
+ status,
+ };
+}
+
+function makeCommandId(prefix) {
+ return `${prefix}-${Date.now()}`;
+}
diff --git a/src/features/host-org/host-org.module.css b/src/features/host-org/host-org.module.css
index 5eb67f4..6c888aa 100644
--- a/src/features/host-org/host-org.module.css
+++ b/src/features/host-org/host-org.module.css
@@ -63,6 +63,7 @@
flex: 0 0 auto;
align-items: center;
gap: var(--space-2);
+ margin-left: auto;
}
.filters {
diff --git a/src/features/host-org/pages/HostAgenciesPage.jsx b/src/features/host-org/pages/HostAgenciesPage.jsx
index 4ed01ef..d8d28a8 100644
--- a/src/features/host-org/pages/HostAgenciesPage.jsx
+++ b/src/features/host-org/pages/HostAgenciesPage.jsx
@@ -1,16 +1,13 @@
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 { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
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 { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.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";
@@ -45,7 +42,7 @@ const agencyColumns = [
{
key: "status",
label: "状态",
- render: (item) =>
,
+ render: (item, _index, context) =>
,
width: "minmax(90px, 0.7fr)",
},
{
@@ -129,46 +126,27 @@ export function HostAgenciesPage() {
0
+ ? {
+ page: page.page,
+ pageSize: page.data.pageSize || 10,
+ total,
+ onPageChange: page.setPage,
+ }
+ : undefined
+ }
rowKey={(item) => item.agencyId}
/>
- {total > 0 ? (
-
- ) : null}
@@ -177,6 +155,7 @@ export function HostAgenciesPage() {
disabled={createDisabled}
loading={page.loadingAction === "agency"}
open={page.activeAction === "agency"}
+ sectionTitle="Agency 信息"
onClose={page.closeAction}
onSubmit={page.submitAgency}
title="创建 Agency"
@@ -211,17 +190,15 @@ export function HostAgenciesPage() {
value={page.agencyForm.maxHosts}
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, maxHosts: event.target.value })}
/>
-
- page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })
- }
- />
+
+ page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })
}
- label={page.agencyForm.joinEnabled ? "允许入会" : "禁止入会"}
/>
-
- page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked })
- }
- />
+
+ page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked })
}
- label={page.joinEnabledForm.joinEnabled ? "允许入会" : "禁止入会"}
/>
);
}
+
+function AgencyStatusSwitch({ item, page }) {
+ const active = item.status === "active";
+ return (
+ {
+ if (!event.target.checked) {
+ page.closeAgencyFromList(item);
+ }
+ }}
+ />
+ );
+}
diff --git a/src/features/host-org/pages/HostBdLeadersPage.jsx b/src/features/host-org/pages/HostBdLeadersPage.jsx
index 9b3df5d..d29e6f5 100644
--- a/src/features/host-org/pages/HostBdLeadersPage.jsx
+++ b/src/features/host-org/pages/HostBdLeadersPage.jsx
@@ -1,14 +1,12 @@
import Add from "@mui/icons-material/Add";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import PersonAddAlt1Outlined from "@mui/icons-material/PersonAddAlt1Outlined";
-import Switch from "@mui/material/Switch";
+import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
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 { SideDrawer } from "@/shared/ui/SideDrawer.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";
@@ -88,19 +86,6 @@ export function HostBdLeadersPage() {
@@ -110,16 +95,18 @@ export function HostBdLeadersPage() {
context={{ page, regionOptions: page.regionOptions }}
items={items}
minWidth="1120px"
+ pagination={
+ total > 0
+ ? {
+ page: page.page,
+ pageSize: page.data.pageSize || 10,
+ total,
+ onPageChange: page.setPage,
+ }
+ : undefined
+ }
rowKey={(item) => `bd-leader-${item.userId}`}
/>
- {total > 0 ? (
-
- ) : null}
@@ -128,6 +115,7 @@ export function HostBdLeadersPage() {
disabled={createDisabled}
loading={page.loadingAction === "bd-leader"}
open={page.activeAction === "bd-leader"}
+ sectionTitle="Leader 信息"
onClose={page.closeAction}
onSubmit={page.submitBDLeader}
title="创建 BD Leader"
@@ -163,6 +151,7 @@ export function HostBdLeadersPage() {
disabled={createDisabled}
loading={page.loadingAction === "leader-add-bd"}
open={page.activeAction === "leader-add-bd"}
+ sectionTitle="下级 BD 信息"
onClose={page.closeAction}
onSubmit={page.submitLeaderBD}
title="增加下级 BD"
@@ -258,7 +247,7 @@ function CreatorIdentity({ item }) {
function BDLeaderStatusSwitch({ item, page }) {
return (
- ,
+ render: (item, _index, context) => ,
width: "minmax(90px, 0.7fr)",
},
{
@@ -64,13 +61,9 @@ const bdColumns = [
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,
@@ -115,46 +108,27 @@ export function HostBdsPage() {
0
+ ? {
+ page: page.page,
+ pageSize: page.data.pageSize || 10,
+ total,
+ onPageChange: page.setPage,
+ }
+ : undefined
+ }
rowKey={(item) => `bd-${item.userId}`}
/>
- {total > 0 ? (
-
- ) : null}
@@ -163,6 +137,7 @@ export function HostBdsPage() {
disabled={createDisabled}
loading={page.loadingAction === "bd"}
open={page.activeAction === "bd"}
+ sectionTitle="BD 信息"
onClose={page.closeAction}
onSubmit={page.submitBD}
title="创建 BD"
@@ -192,45 +167,6 @@ export function HostBdsPage() {
onChange={(event) => 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 })}
- />
-
);
}
@@ -260,6 +196,17 @@ function ParentLeader({ item }) {
);
}
+function BDStatusSwitch({ item, page }) {
+ return (
+ page.toggleBD(item, event.target.checked)}
+ />
+ );
+}
+
function Creator({ item }) {
if (!item.createdByUserId && !item.createdByDisplayUserId && !item.createdByUsername) {
return "-";
diff --git a/src/features/host-org/pages/HostCoinSellersPage.jsx b/src/features/host-org/pages/HostCoinSellersPage.jsx
index d667922..2c378cd 100644
--- a/src/features/host-org/pages/HostCoinSellersPage.jsx
+++ b/src/features/host-org/pages/HostCoinSellersPage.jsx
@@ -1,7 +1,7 @@
import Add from "@mui/icons-material/Add";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import MenuItem from "@mui/material/MenuItem";
-import Switch from "@mui/material/Switch";
+import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import {
AdminFormAmountField,
@@ -11,7 +11,6 @@ import {
} from "@/shared/ui/AdminFormDialog.jsx";
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";
@@ -131,19 +130,6 @@ export function HostCoinSellersPage() {
@@ -153,17 +139,19 @@ export function HostCoinSellersPage() {
context={{ page, regionOptions: page.regionOptions }}
items={items}
minWidth="1040px"
+ pagination={
+ total > 0
+ ? {
+ page: page.page,
+ pageSize: page.data.pageSize || 10,
+ total,
+ onPageChange: page.setPage,
+ }
+ : undefined
+ }
regionFilter={regionFilter}
rowKey={(item) => item.userId}
/>
- {total > 0 ? (
-
- ) : null}
@@ -172,6 +160,7 @@ export function HostCoinSellersPage() {
disabled={createDisabled}
loading={page.loadingAction === "coin-seller-create"}
open={page.activeAction === "create"}
+ sectionTitle="币商信息"
onClose={page.closeAction}
onSubmit={page.submitCreateSeller}
title="添加币商"
@@ -196,6 +185,7 @@ export function HostCoinSellersPage() {
disabled={updateDisabled}
loading={String(page.loadingAction).startsWith("coin-seller-edit")}
open={page.activeAction === "edit"}
+ sectionTitle="联系方式"
onClose={page.closeAction}
onSubmit={page.submitEditSeller}
title="编辑联系方式"
@@ -213,6 +203,7 @@ export function HostCoinSellersPage() {
disabled={!page.abilities.canStockCredit || page.selectedSeller?.status !== "active"}
loading={String(page.loadingAction).startsWith("coin-seller-stock")}
open={page.activeAction === "stock"}
+ sectioned={false}
size="standard"
submitLabel="确认入账"
onClose={page.closeAction}
@@ -308,7 +299,7 @@ function SellerIdentity({ item }) {
function SellerStatusSwitch({ item, page }) {
return (
-
- }
actions={
page.abilities.canCreate ? (
@@ -113,6 +108,7 @@ export function HostCountriesPage() {
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
loading={page.loadingAction === "country-create" || page.loadingAction === "country-edit"}
open={page.activeAction === "create" || page.activeAction === "edit"}
+ sectionTitle="国家信息"
onClose={page.closeAction}
onSubmit={page.submitCountry}
title={page.activeAction === "edit" ? "编辑国家" : "创建国家"}
@@ -174,17 +170,15 @@ export function HostCountriesPage() {
onChange={(event) => page.setCountryForm({ ...page.countryForm, sortOrder: event.target.value })}
/>
{page.activeAction === "create" ? (
-
- page.setCountryForm({ ...page.countryForm, enabled: event.target.checked })
- }
- />
+
+ page.setCountryForm({ ...page.countryForm, enabled: event.target.checked })
}
- label={page.countryForm.enabled ? "启用" : "停用"}
/>
) : null}
@@ -206,7 +200,7 @@ function CountryActions({ item, page }) {
function CountryStatusSwitch({ item, page }) {
return (
- ,
+ render: (item) => ,
width: "minmax(90px, 0.7fr)",
},
{
@@ -110,30 +108,6 @@ export function HostHostsPage() {
return (
-
-
0
+ ? {
+ page: page.page,
+ pageSize: page.data.pageSize || 10,
+ total,
+ onPageChange: page.setPage,
+ }
+ : undefined
+ }
rowKey={(item) => item.userId}
/>
- {total > 0 ? (
-
- ) : null}
@@ -168,3 +144,13 @@ function HostUser({ item }) {
/>
);
}
+
+function HostStatusSwitch({ item }) {
+ return (
+
+ );
+}
diff --git a/src/features/host-org/pages/HostManagersPage.jsx b/src/features/host-org/pages/HostManagersPage.jsx
new file mode 100644
index 0000000..69a3cc2
--- /dev/null
+++ b/src/features/host-org/pages/HostManagersPage.jsx
@@ -0,0 +1,168 @@
+import { formatMillis } from "@/shared/utils/time.js";
+import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
+import { DataState } from "@/shared/ui/DataState.jsx";
+import { agencyStatusFilters } from "@/features/host-org/constants.js";
+import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
+import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
+import { useHostManagersPage } from "@/features/host-org/hooks/useHostManagersPage.js";
+import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
+import styles from "@/features/host-org/host-org.module.css";
+
+const managerColumns = [
+ {
+ key: "ownerUserId",
+ label: "经理",
+ render: (item) => ,
+ width: "minmax(220px, 1.25fr)",
+ },
+ {
+ key: "agency",
+ label: "Agency",
+ render: (item) => ,
+ width: "minmax(190px, 1fr)",
+ },
+ {
+ key: "parentBdUserId",
+ label: "上级 BD",
+ render: (item) => ,
+ width: "minmax(220px, 1.2fr)",
+ },
+ {
+ key: "regionId",
+ label: "区域",
+ render: (item, _index, context) => hostOrgRegionName(item, context?.regionOptions),
+ width: "minmax(130px, 0.75fr)",
+ },
+ {
+ key: "status",
+ label: "状态",
+ render: (item, _index, context) => ,
+ width: "minmax(90px, 0.7fr)",
+ },
+ {
+ key: "joinEnabled",
+ label: "入会",
+ render: (item) => (
+
+ {item.joinEnabled ? "允许" : "禁止"}
+
+ ),
+ width: "minmax(80px, 0.6fr)",
+ },
+ { key: "maxHosts", label: "最大主播数", width: "minmax(100px, 0.7fr)" },
+ {
+ key: "updatedAtMs",
+ label: "更新时间",
+ render: (item) => formatMillis(item.updatedAtMs),
+ width: "minmax(170px, 1fr)",
+ },
+];
+
+export function HostManagersPage() {
+ const page = useHostManagersPage();
+ const items = page.data.items || [];
+ const total = page.data.total || 0;
+ const columns = managerColumns.map((column) => {
+ if (column.key === "ownerUserId") {
+ return {
+ ...column,
+ filter: createTextColumnFilter({
+ placeholder: "搜索经理、Agency、用户",
+ value: page.query,
+ onChange: page.changeQuery,
+ }),
+ };
+ }
+ if (column.key === "parentBdUserId") {
+ return {
+ ...column,
+ filter: createTextColumnFilter({
+ placeholder: "搜索上级 BD 用户 ID",
+ value: page.parentBdUserId,
+ onChange: page.changeParentBdUserId,
+ }),
+ };
+ }
+ if (column.key === "status") {
+ return {
+ ...column,
+ filter: createOptionsColumnFilter({
+ options: agencyStatusFilters,
+ placeholder: "搜索状态",
+ value: page.status,
+ onChange: page.changeStatus,
+ }),
+ };
+ }
+ return column;
+ });
+
+ return (
+
+
+
+
+ 0
+ ? {
+ page: page.page,
+ pageSize: page.data.pageSize || 10,
+ total,
+ onPageChange: page.setPage,
+ }
+ : undefined
+ }
+ rowKey={(item) => `manager-${item.ownerUserId || item.agencyId}`}
+ />
+
+
+
+
+ );
+}
+
+function ManagerUser({ item }) {
+ return (
+
+ );
+}
+
+function ParentBD({ item }) {
+ if (!item.parentBdUserId) {
+ return "-";
+ }
+ return (
+
+ );
+}
+
+function ManagerStatusSwitch({ item, page }) {
+ const active = item.status === "active";
+ return (
+ {
+ if (!event.target.checked) {
+ page.closeManagerAgency(item);
+ }
+ }}
+ />
+ );
+}
diff --git a/src/features/host-org/pages/HostRegionsPage.jsx b/src/features/host-org/pages/HostRegionsPage.jsx
index 456a01f..51751e3 100644
--- a/src/features/host-org/pages/HostRegionsPage.jsx
+++ b/src/features/host-org/pages/HostRegionsPage.jsx
@@ -1,11 +1,10 @@
import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
-import Switch from "@mui/material/Switch";
+import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
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 { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
import { regionStatusFilters } from "@/features/host-org/constants.js";
@@ -81,17 +80,8 @@ export function HostRegionsPage() {
return (
-
-
-
- {page.abilities.canCreate ? (
+ {page.abilities.canCreate ? (
+
@@ -101,8 +91,8 @@ export function HostRegionsPage() {
- ) : null}
-
+
+ ) : null}
@@ -118,6 +108,7 @@ export function HostRegionsPage() {
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
loading={page.loadingAction === "region-create" || page.loadingAction === "region-edit"}
open={page.activeAction === "create" || page.activeAction === "edit"}
+ sectionTitle="区域信息"
onClose={page.closeAction}
onSubmit={page.submitRegion}
title={page.activeAction === "edit" ? "编辑区域" : "创建区域"}
@@ -182,7 +173,7 @@ function RegionStatusSwitch({ item, page }) {
const checked = item.status === "active";
const isGlobal = item.regionCode === "GLOBAL";
return (
-
import("./pages/HostManagersPage.jsx").then((module) => module.HostManagersPage),
+ menuCode: MENU_CODES.hostOrgManagers,
+ pageKey: "host-org-managers",
+ path: "/host/managers",
+ permission: PERMISSIONS.agencyView
+ },
+ {
+ label: "Agency 列表",
loader: () => import("./pages/HostAgenciesPage.jsx").then((module) => module.HostAgenciesPage),
menuCode: MENU_CODES.hostOrgAgencies,
pageKey: "host-org-agencies",
@@ -26,7 +34,7 @@ export const hostOrgRoutes = [
permission: PERMISSIONS.agencyView
},
{
- label: "BD Leader 管理",
+ label: "BD Leader 列表",
loader: () => import("./pages/HostBdLeadersPage.jsx").then((module) => module.HostBdLeadersPage),
menuCode: MENU_CODES.hostOrgBdLeaders,
pageKey: "host-org-bd-leaders",
@@ -34,7 +42,7 @@ export const hostOrgRoutes = [
permission: PERMISSIONS.bdView
},
{
- label: "BD 管理",
+ label: "BD 列表",
loader: () => import("./pages/HostBdsPage.jsx").then((module) => module.HostBdsPage),
menuCode: MENU_CODES.hostOrgBds,
pageKey: "host-org-bds",
@@ -42,7 +50,7 @@ export const hostOrgRoutes = [
permission: PERMISSIONS.bdView
},
{
- label: "主播管理",
+ label: "主播列表",
loader: () => import("./pages/HostHostsPage.jsx").then((module) => module.HostHostsPage),
menuCode: MENU_CODES.hostOrgHosts,
pageKey: "host-org-hosts",
@@ -50,7 +58,7 @@ export const hostOrgRoutes = [
permission: PERMISSIONS.hostView
},
{
- label: "币商管理",
+ label: "币商列表",
loader: () => import("./pages/HostCoinSellersPage.jsx").then((module) => module.HostCoinSellersPage),
menuCode: MENU_CODES.hostOrgCoinSellers,
pageKey: "host-org-coin-sellers",
diff --git a/src/features/logs/components/LogFilters.jsx b/src/features/logs/components/LogFilters.jsx
deleted file mode 100644
index 48b9606..0000000
--- a/src/features/logs/components/LogFilters.jsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
-
-export function LogFilters({ onReset, query }) {
- return (
-
- );
-}
diff --git a/src/features/logs/components/LogTable.jsx b/src/features/logs/components/LogTable.jsx
index 7bd8758..895ae22 100644
--- a/src/features/logs/components/LogTable.jsx
+++ b/src/features/logs/components/LogTable.jsx
@@ -1,7 +1,7 @@
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
-export function LogTable({ data, isLogin, onQueryChange, query }) {
+export function LogTable({ data, isLogin, onQueryChange, pagination, query }) {
const items = data.items || [];
const columns = isLogin
? [
@@ -68,9 +68,8 @@ export function LogTable({ data, isLogin, onQueryChange, query }) {
columns={columns}
items={items}
minWidth={isLogin ? "920px" : "1080px"}
+ pagination={pagination}
rowKey={(item) => item.id}
- title={isLogin ? "登录记录" : "操作记录"}
- total={data.total}
/>
);
}
diff --git a/src/features/logs/pages/LogsPage.jsx b/src/features/logs/pages/LogsPage.jsx
index 2480d5a..65b4cbf 100644
--- a/src/features/logs/pages/LogsPage.jsx
+++ b/src/features/logs/pages/LogsPage.jsx
@@ -1,6 +1,4 @@
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";
@@ -11,15 +9,19 @@ export function LogsPage({ type }) {
return (
<>
-
-
-
>
diff --git a/src/features/menus/components/MenuFormDrawer.jsx b/src/features/menus/components/MenuFormDrawer.jsx
index 9832a2c..a388923 100644
--- a/src/features/menus/components/MenuFormDrawer.jsx
+++ b/src/features/menus/components/MenuFormDrawer.jsx
@@ -1,6 +1,5 @@
import Drawer from "@mui/material/Drawer";
-import FormControlLabel from "@mui/material/FormControlLabel";
-import Switch from "@mui/material/Switch";
+import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import { Button } from "@/shared/ui/Button.jsx";
@@ -9,17 +8,30 @@ export function MenuFormDrawer({ editingMenu, form, onClose, onSubmit, open, set
);
@@ -230,7 +210,7 @@ function RoomIdentity({ onClick, room }) {
return (