优化布局
This commit is contained in:
parent
dd8cd5d952
commit
4dad53162e
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.DS_Store
|
||||
coverage
|
||||
run
|
||||
*.log
|
||||
@ -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 预览。
|
||||
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
34
deploy/nginx.conf
Normal file
34
deploy/nginx.conf
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
<IconButton label="刷新" onClick={refresh}>
|
||||
<Refresh fontSize="small" />
|
||||
</IconButton>
|
||||
{canViewNotifications ? (
|
||||
<IconButton label="通知" notice={unread ? String(unread) : undefined} onClick={openNotifications}>
|
||||
<NotificationsNoneOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
) : null}
|
||||
<button className="user-pill" type="button" onClick={(event) => setUserMenuAnchor(event.currentTarget)}>
|
||||
<Person fontSize="small" />
|
||||
<span>{user?.name || user?.account || "admin"}</span>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined";
|
||||
import BedroomParentOutlined from "@mui/icons-material/BedroomParentOutlined";
|
||||
import BlockOutlined from "@mui/icons-material/BlockOutlined";
|
||||
import CampaignOutlined from "@mui/icons-material/CampaignOutlined";
|
||||
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
|
||||
@ -13,12 +14,12 @@ import LoginOutlined from "@mui/icons-material/LoginOutlined";
|
||||
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
|
||||
import MapOutlined from "@mui/icons-material/MapOutlined";
|
||||
import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
|
||||
import NotificationsNoneOutlined from "@mui/icons-material/NotificationsNoneOutlined";
|
||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||
import SendOutlined from "@mui/icons-material/SendOutlined";
|
||||
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
|
||||
import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicationsOutlined";
|
||||
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
|
||||
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
|
||||
import WalletOutlined from "@mui/icons-material/WalletOutlined";
|
||||
import { getRouteByMenuCode } from "@/app/router/routeConfig";
|
||||
@ -31,6 +32,7 @@ const menuLabelOverrides = {
|
||||
|
||||
const iconMap = {
|
||||
apartment: ApartmentOutlined,
|
||||
block: BlockOutlined,
|
||||
campaign: CampaignOutlined,
|
||||
category: CategoryOutlined,
|
||||
dashboard: DashboardOutlined,
|
||||
@ -43,13 +45,14 @@ const iconMap = {
|
||||
login: LoginOutlined,
|
||||
map: MapOutlined,
|
||||
menu: MenuOpenOutlined,
|
||||
notifications: NotificationsNoneOutlined,
|
||||
operations: ReceiptLongOutlined,
|
||||
public: PublicOutlined,
|
||||
receipt: ReceiptLongOutlined,
|
||||
room: BedroomParentOutlined,
|
||||
send: SendOutlined,
|
||||
settings: SettingsApplicationsOutlined,
|
||||
shield: ShieldOutlined,
|
||||
sports_esports: SportsEsportsOutlined,
|
||||
task: TaskAltOutlined,
|
||||
users: ManageAccountsOutlined,
|
||||
wallet: WalletOutlined,
|
||||
@ -77,6 +80,7 @@ export const fallbackNavigation = [
|
||||
label: "用户管理",
|
||||
children: [
|
||||
routeNavItem("app-user-list", { icon: ManageAccountsOutlined }),
|
||||
routeNavItem("app-user-bans", { icon: BlockOutlined }),
|
||||
routeNavItem("app-user-login-logs", { icon: LoginOutlined }),
|
||||
],
|
||||
},
|
||||
@ -95,6 +99,8 @@ export const fallbackNavigation = [
|
||||
children: [
|
||||
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
|
||||
routeNavItem("app-config-banners", { icon: ImageOutlined }),
|
||||
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
|
||||
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -109,12 +115,22 @@ export const fallbackNavigation = [
|
||||
routeNavItem("resource-grant-list", { icon: SendOutlined }),
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "operations",
|
||||
icon: ReceiptLongOutlined,
|
||||
id: "operations",
|
||||
label: "运营管理",
|
||||
children: [routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined })],
|
||||
},
|
||||
{
|
||||
code: "activities",
|
||||
icon: CampaignOutlined,
|
||||
id: "activities",
|
||||
label: "活动管理",
|
||||
children: [routeNavItem("daily-task-list", { icon: TaskAltOutlined })],
|
||||
children: [
|
||||
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
|
||||
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "payment",
|
||||
@ -123,6 +139,13 @@ export const fallbackNavigation = [
|
||||
label: "支付管理",
|
||||
children: [routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })],
|
||||
},
|
||||
{
|
||||
code: "games",
|
||||
icon: SportsEsportsOutlined,
|
||||
id: "games",
|
||||
label: "游戏管理",
|
||||
children: [routeNavItem("game-list", { icon: SportsEsportsOutlined })],
|
||||
},
|
||||
{
|
||||
code: "geo",
|
||||
icon: MapOutlined,
|
||||
@ -139,6 +162,7 @@ export const fallbackNavigation = [
|
||||
id: "host-org",
|
||||
label: "团队管理",
|
||||
children: [
|
||||
routeNavItem("host-org-managers", { icon: ManageAccountsOutlined }),
|
||||
routeNavItem("host-org-agencies", { icon: ApartmentOutlined }),
|
||||
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
|
||||
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
|
||||
@ -146,11 +170,10 @@ export const fallbackNavigation = [
|
||||
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
|
||||
],
|
||||
},
|
||||
routeNavItem("notifications", { icon: NotificationsNoneOutlined }),
|
||||
];
|
||||
|
||||
export function mapBackendMenus(menus = []) {
|
||||
return relocateLogMenusToSystem(menus)
|
||||
return relocateBackendMenus(menus)
|
||||
.map((item) => {
|
||||
if (deprecatedMenuCodes.has(item.code)) {
|
||||
return null;
|
||||
@ -172,6 +195,38 @@ export function mapBackendMenus(menus = []) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function relocateBackendMenus(menus = []) {
|
||||
return relocateRechargeProductsToAppConfig(relocateLogMenusToSystem(menus));
|
||||
}
|
||||
|
||||
function relocateRechargeProductsToAppConfig(menus = []) {
|
||||
const appConfigMenu = menus.find((item) => item.code === "app-config");
|
||||
const paymentMenu = menus.find((item) => item.code === "payment");
|
||||
const rechargeProductMenu = paymentMenu?.children?.find((item) => item.code === "payment-recharge-products");
|
||||
if (!appConfigMenu || !rechargeProductMenu) {
|
||||
return menus;
|
||||
}
|
||||
|
||||
return menus
|
||||
.map((item) => {
|
||||
if (item.code === "app-config") {
|
||||
const children = item.children || [];
|
||||
if (children.some((child) => child.code === "payment-recharge-products")) {
|
||||
return item;
|
||||
}
|
||||
return { ...item, children: [...children, rechargeProductMenu] };
|
||||
}
|
||||
if (item.code === "payment") {
|
||||
return {
|
||||
...item,
|
||||
children: (item.children || []).filter((child) => child.code !== "payment-recharge-products"),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
.filter((item) => item.code !== "payment" || item.path || item.children?.length);
|
||||
}
|
||||
|
||||
function relocateLogMenusToSystem(menus = []) {
|
||||
const systemMenu = menus.find((item) => item.code === "system");
|
||||
const logsMenu = menus.find((item) => item.code === "logs");
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { fallbackNavigation, filterNavigationByPermission, mergeNavigationItems } from "./menu.js";
|
||||
import { fallbackNavigation, filterNavigationByPermission, mapBackendMenus, mergeNavigationItems } from "./menu.js";
|
||||
|
||||
describe("navigation menu helpers", () => {
|
||||
test("keeps only fallback menu items allowed by permissions", () => {
|
||||
@ -10,6 +10,7 @@ describe("navigation menu helpers", () => {
|
||||
|
||||
expect(flattenCodes(items)).not.toContain("geo");
|
||||
expect(flattenCodes(items)).toContain("host-org");
|
||||
expect(flattenCodes(items)).toContain("host-org-managers");
|
||||
expect(flattenCodes(items)).toContain("host-org-agencies");
|
||||
expect(flattenCodes(items)).toContain("host-org-hosts");
|
||||
expect(flattenCodes(items)).not.toContain("host-org-bds");
|
||||
@ -51,6 +52,37 @@ describe("navigation menu helpers", () => {
|
||||
const merged = mergeNavigationItems(backendMenus, fallbackMenus);
|
||||
|
||||
expect(flattenCodes(merged)).toContain("host-org-agencies");
|
||||
expect(flattenCodes(merged)).toContain("host-org-managers");
|
||||
});
|
||||
|
||||
test("places recharge products under app config even when backend menu is stale", () => {
|
||||
const mapped = mapBackendMenus([
|
||||
{ children: [], code: "app-config", icon: "settings", id: "app-config", label: "APP配置" },
|
||||
{
|
||||
children: [
|
||||
{
|
||||
code: "payment-recharge-products",
|
||||
icon: "wallet",
|
||||
id: "payment-recharge-products",
|
||||
label: "内购配置",
|
||||
path: "/payment/recharge-products",
|
||||
permissionCode: "payment-product:view",
|
||||
},
|
||||
],
|
||||
code: "payment",
|
||||
icon: "wallet",
|
||||
id: "payment",
|
||||
label: "支付管理",
|
||||
},
|
||||
]);
|
||||
const appConfig = mapped.find((item) => item.code === "app-config");
|
||||
const payment = mapped.find((item) => item.code === "payment");
|
||||
|
||||
expect(appConfig.children.map((item) => item.code)).toContain("payment-recharge-products");
|
||||
expect(appConfig.children.find((item) => item.code === "payment-recharge-products").path).toBe(
|
||||
"/app-config/recharge-products",
|
||||
);
|
||||
expect(payment).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -25,7 +25,16 @@ export const PERMISSIONS = {
|
||||
coinSellerCreate: "coin-seller:create",
|
||||
coinSellerUpdate: "coin-seller:update",
|
||||
coinSellerStockCredit: "coin-seller:stock-credit",
|
||||
coinLedgerView: "coin-ledger:view",
|
||||
paymentBillView: "payment-bill:view",
|
||||
paymentProductView: "payment-product:view",
|
||||
paymentProductCreate: "payment-product:create",
|
||||
paymentProductUpdate: "payment-product:update",
|
||||
paymentProductDelete: "payment-product:delete",
|
||||
gameView: "game:view",
|
||||
gameCreate: "game:create",
|
||||
gameUpdate: "game:update",
|
||||
gameStatus: "game:status",
|
||||
roleView: "role:view",
|
||||
roleCreate: "role:create",
|
||||
roleUpdate: "role:update",
|
||||
@ -46,10 +55,6 @@ export const PERMISSIONS = {
|
||||
menuVisible: "menu:visible",
|
||||
logView: "log:view",
|
||||
logExport: "log:export",
|
||||
notificationView: "notification:view",
|
||||
notificationRead: "notification:read",
|
||||
notificationReadAll: "notification:read-all",
|
||||
notificationDelete: "notification:delete",
|
||||
appUserView: "app-user:view",
|
||||
appUserUpdate: "app-user:update",
|
||||
appUserStatus: "app-user:status",
|
||||
@ -57,8 +62,14 @@ export const PERMISSIONS = {
|
||||
roomView: "room:view",
|
||||
roomUpdate: "room:update",
|
||||
roomDelete: "room:delete",
|
||||
roomConfigView: "room-config:view",
|
||||
roomConfigUpdate: "room-config:update",
|
||||
appConfigView: "app-config:view",
|
||||
appConfigUpdate: "app-config:update",
|
||||
appVersionView: "app-version:view",
|
||||
appVersionCreate: "app-version:create",
|
||||
appVersionUpdate: "app-version:update",
|
||||
appVersionDelete: "app-version:delete",
|
||||
resourceView: "resource:view",
|
||||
resourceCreate: "resource:create",
|
||||
resourceUpdate: "resource:update",
|
||||
@ -75,6 +86,8 @@ export const PERMISSIONS = {
|
||||
dailyTaskCreate: "daily-task:create",
|
||||
dailyTaskUpdate: "daily-task:update",
|
||||
dailyTaskStatus: "daily-task:status",
|
||||
registrationRewardView: "registration-reward:view",
|
||||
registrationRewardUpdate: "registration-reward:update",
|
||||
uploadCreate: "upload:create",
|
||||
jobView: "job:view",
|
||||
jobCancel: "job:cancel",
|
||||
@ -94,26 +107,32 @@ export const MENU_CODES = {
|
||||
logs: "logs",
|
||||
logsLogin: "logs-login",
|
||||
logsOperations: "logs-operations",
|
||||
notifications: "notifications",
|
||||
appUsers: "app-users",
|
||||
appUserList: "app-user-list",
|
||||
appUserBans: "app-user-bans",
|
||||
appUserLoginLogs: "app-user-login-logs",
|
||||
rooms: "rooms",
|
||||
roomList: "room-list",
|
||||
roomConfig: "room-config",
|
||||
appConfig: "app-config",
|
||||
appConfigH5: "app-config-h5",
|
||||
appConfigBanners: "app-config-banners",
|
||||
appConfigVersions: "app-config-versions",
|
||||
resources: "resources",
|
||||
resourceList: "resource-list",
|
||||
resourceGroupList: "resource-group-list",
|
||||
resourceGrantList: "resource-grant-list",
|
||||
giftList: "gift-list",
|
||||
operations: "operations",
|
||||
operationCoinLedger: "operation-coin-ledger",
|
||||
activities: "activities",
|
||||
dailyTaskList: "daily-task-list",
|
||||
registrationReward: "registration-reward",
|
||||
geo: "geo",
|
||||
hostOrg: "host-org",
|
||||
hostOrgCountries: "host-org-countries",
|
||||
hostOrgRegions: "host-org-regions",
|
||||
hostOrgManagers: "host-org-managers",
|
||||
hostOrgAgencies: "host-org-agencies",
|
||||
hostOrgBdLeaders: "host-org-bd-leaders",
|
||||
hostOrgBds: "host-org-bds",
|
||||
@ -121,6 +140,9 @@ export const MENU_CODES = {
|
||||
hostOrgCoinSellers: "host-org-coin-sellers",
|
||||
payment: "payment",
|
||||
paymentBillList: "payment-bill-list",
|
||||
paymentRechargeProducts: "payment-recharge-products",
|
||||
games: "games",
|
||||
gameList: "game-list",
|
||||
jobs: "jobs",
|
||||
} as const;
|
||||
|
||||
|
||||
@ -3,11 +3,13 @@ import { appConfigRoutes } from "@/features/app-config/routes.js";
|
||||
import { appUserRoutes } from "@/features/app-users/routes.js";
|
||||
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
||||
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
||||
import { gameRoutes } from "@/features/games/routes.js";
|
||||
import { hostOrgRoutes } from "@/features/host-org/routes.js";
|
||||
import { logsRoutes } from "@/features/logs/routes.js";
|
||||
import { menusRoutes } from "@/features/menus/routes.js";
|
||||
import { notificationsRoutes } from "@/features/notifications/routes.js";
|
||||
import { operationsRoutes } from "@/features/operations/routes.js";
|
||||
import { paymentRoutes } from "@/features/payment/routes.js";
|
||||
import { registrationRewardRoutes } from "@/features/registration-reward/routes.js";
|
||||
import { resourceRoutes } from "@/features/resources/routes.js";
|
||||
import { rolesRoutes } from "@/features/roles/routes.js";
|
||||
import { roomRoutes } from "@/features/rooms/routes.js";
|
||||
@ -23,14 +25,16 @@ export const adminRoutes: AdminRoute[] = [
|
||||
...roomRoutes,
|
||||
...appConfigRoutes,
|
||||
...dailyTaskRoutes,
|
||||
...registrationRewardRoutes,
|
||||
...resourceRoutes,
|
||||
...operationsRoutes,
|
||||
...paymentRoutes,
|
||||
...gameRoutes,
|
||||
...usersRoutes,
|
||||
...hostOrgRoutes,
|
||||
...rolesRoutes,
|
||||
...menusRoutes,
|
||||
...logsRoutes,
|
||||
...notificationsRoutes,
|
||||
];
|
||||
|
||||
export const defaultAdminPath = "/overview";
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
import { apiRequest } from "@/shared/api/request";
|
||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import type { ApiList, AppBannerDto, AppBannerPayload, EntityId, H5LinkConfigDto, H5LinkConfigUpdatePayload, PageQuery } from "@/shared/api/types";
|
||||
import type {
|
||||
ApiList,
|
||||
AppBannerDto,
|
||||
AppBannerPayload,
|
||||
AppVersionDto,
|
||||
AppVersionPayload,
|
||||
EntityId,
|
||||
H5LinkConfigDto,
|
||||
H5LinkConfigUpdatePayload,
|
||||
PageQuery,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
|
||||
const endpoint = API_ENDPOINTS.listH5Links;
|
||||
@ -47,3 +57,37 @@ export function deleteBanner(bannerId: EntityId): Promise<{ deleted: boolean }>
|
||||
method: endpoint.method
|
||||
});
|
||||
}
|
||||
|
||||
export function listAppVersions(query: PageQuery = {}): Promise<ApiList<AppVersionDto>> {
|
||||
const endpoint = API_ENDPOINTS.listAppVersions;
|
||||
return apiRequest<ApiList<AppVersionDto>>(apiEndpointPath(API_OPERATIONS.listAppVersions), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
});
|
||||
}
|
||||
|
||||
export function createAppVersion(payload: AppVersionPayload): Promise<AppVersionDto> {
|
||||
const endpoint = API_ENDPOINTS.createAppVersion;
|
||||
return apiRequest<AppVersionDto, AppVersionPayload>(apiEndpointPath(API_OPERATIONS.createAppVersion), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAppVersion(versionId: EntityId, payload: AppVersionPayload): Promise<AppVersionDto> {
|
||||
const endpoint = API_ENDPOINTS.updateAppVersion;
|
||||
return apiRequest<AppVersionDto, AppVersionPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateAppVersion, { version_id: versionId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteAppVersion(versionId: EntityId): Promise<{ deleted: boolean }> {
|
||||
const endpoint = API_ENDPOINTS.deleteAppVersion;
|
||||
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteAppVersion, { version_id: versionId }), {
|
||||
method: endpoint.method
|
||||
});
|
||||
}
|
||||
|
||||
@ -23,3 +23,7 @@
|
||||
.bannerCover {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.formWideField {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
167
src/features/app-config/hooks/useAppVersionsPage.js
Normal file
167
src/features/app-config/hooks/useAppVersionsPage.js
Normal file
@ -0,0 +1,167 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { createAppVersion, deleteAppVersion, listAppVersions, updateAppVersion } from "@/features/app-config/api";
|
||||
import { useAppConfigAbilities } from "@/features/app-config/permissions.js";
|
||||
import { appVersionSchema } from "@/features/app-config/schema";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
|
||||
const emptyData = { items: [], total: 0 };
|
||||
|
||||
const emptyForm = () => ({
|
||||
buildNumber: "",
|
||||
description: "",
|
||||
downloadUrl: "",
|
||||
forceUpdate: false,
|
||||
platform: "android",
|
||||
version: ""
|
||||
});
|
||||
|
||||
export function useAppVersionsPage() {
|
||||
const abilities = useAppConfigAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [platform, setPlatform] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [form, setFormState] = useState(emptyForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
|
||||
const filters = useMemo(() => ({ keyword, platform }), [keyword, platform]);
|
||||
const queryFn = useCallback(() => listAppVersions(filters), [filters]);
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload
|
||||
} = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载版本配置失败",
|
||||
initialData: emptyData,
|
||||
queryKey: ["app-config", "versions", filters]
|
||||
});
|
||||
|
||||
const setForm = (patch) => {
|
||||
setFormState((current) => ({ ...current, ...patch }));
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingItem(null);
|
||||
setFormState(emptyForm());
|
||||
setActiveAction("create");
|
||||
};
|
||||
|
||||
const openEdit = (item) => {
|
||||
setEditingItem(item);
|
||||
setFormState(formFromVersion(item));
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setActiveAction("");
|
||||
setEditingItem(null);
|
||||
setFormState(emptyForm());
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setKeyword("");
|
||||
setPlatform("");
|
||||
};
|
||||
|
||||
const submitVersion = async (event) => {
|
||||
event.preventDefault();
|
||||
const payload = parseForm(appVersionSchema, normalizeForm(form));
|
||||
const action = editingItem ? "edit" : "create";
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
if (editingItem) {
|
||||
await updateAppVersion(editingItem.id, payload);
|
||||
showToast("版本配置已更新", "success");
|
||||
} else {
|
||||
await createAppVersion(payload);
|
||||
showToast("版本配置已新增", "success");
|
||||
}
|
||||
closeDialog();
|
||||
await reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeVersion = async (item) => {
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: `${platformLabel(item.platform)} ${item.version} (${item.buildNumber})`,
|
||||
title: "删除版本配置",
|
||||
tone: "danger"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
setLoadingAction(`delete:${item.id}`);
|
||||
try {
|
||||
await deleteAppVersion(item.id);
|
||||
await reload();
|
||||
showToast("版本配置已删除", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "删除失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
closeDialog,
|
||||
data,
|
||||
editingItem,
|
||||
error,
|
||||
form,
|
||||
keyword,
|
||||
loading,
|
||||
loadingAction,
|
||||
openCreate,
|
||||
openEdit,
|
||||
platform,
|
||||
reload,
|
||||
removeVersion,
|
||||
resetFilters,
|
||||
setForm,
|
||||
setKeyword,
|
||||
setPlatform,
|
||||
submitVersion
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeForm(form) {
|
||||
return {
|
||||
...form,
|
||||
buildNumber: form.buildNumber || 0,
|
||||
description: form.description || ""
|
||||
};
|
||||
}
|
||||
|
||||
function formFromVersion(item) {
|
||||
return {
|
||||
buildNumber: item.buildNumber === 0 || item.buildNumber ? String(item.buildNumber) : "",
|
||||
description: item.description || "",
|
||||
downloadUrl: item.downloadUrl || "",
|
||||
forceUpdate: Boolean(item.forceUpdate),
|
||||
platform: item.platform || "android",
|
||||
version: item.version || ""
|
||||
};
|
||||
}
|
||||
|
||||
function platformLabel(value) {
|
||||
if (value === "android") {
|
||||
return "Android";
|
||||
}
|
||||
if (value === "ios") {
|
||||
return "iOS";
|
||||
}
|
||||
return value || "-";
|
||||
}
|
||||
242
src/features/app-config/pages/AppVersionManagementPage.jsx
Normal file
242
src/features/app-config/pages/AppVersionManagementPage.jsx
Normal file
@ -0,0 +1,242 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo } from "react";
|
||||
import { useAppVersionsPage } from "@/features/app-config/hooks/useAppVersionsPage.js";
|
||||
import styles from "@/features/app-config/app-config.module.css";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import {
|
||||
AdminFormDialog,
|
||||
AdminFormFieldGrid,
|
||||
AdminFormSection,
|
||||
AdminFormSwitchField
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
|
||||
const platformOptions = [
|
||||
["", "全部平台"],
|
||||
["android", "Android"],
|
||||
["ios", "iOS"]
|
||||
];
|
||||
|
||||
export function AppVersionManagementPage() {
|
||||
const page = useAppVersionsPage();
|
||||
const items = page.data.items || [];
|
||||
const columns = useMemo(() => versionColumns(page), [page]);
|
||||
const saving = page.loadingAction === "create" || page.loadingAction === "edit";
|
||||
const canSubmit = page.editingItem ? page.abilities.canUpdateVersion : page.abilities.canCreateVersion;
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
page.abilities.canCreateVersion ? (
|
||||
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={page.openCreate}>
|
||||
新增版本
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable columns={columns} items={items} minWidth="1080px" rowKey={(item) => item.id} />
|
||||
<div className="pagination-bar">
|
||||
<span>{items.length} 条</span>
|
||||
</div>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
|
||||
<AdminFormDialog
|
||||
loading={saving}
|
||||
open={Boolean(page.activeAction)}
|
||||
size="narrow"
|
||||
submitDisabled={!canSubmit || saving}
|
||||
title={page.editingItem ? "编辑版本" : "新增版本"}
|
||||
onClose={page.closeDialog}
|
||||
onSubmit={page.submitVersion}
|
||||
>
|
||||
<AdminFormSection title="版本信息">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
label="平台"
|
||||
required
|
||||
select
|
||||
value={page.form.platform}
|
||||
onChange={(event) => page.setForm({ platform: event.target.value })}
|
||||
>
|
||||
<MenuItem value="android">Android</MenuItem>
|
||||
<MenuItem value="ios">iOS</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
label="版本号"
|
||||
required
|
||||
value={page.form.version}
|
||||
onChange={(event) => page.setForm({ version: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
inputProps={{ min: 1, step: 1 }}
|
||||
label="编译版本号"
|
||||
required
|
||||
type="number"
|
||||
value={page.form.buildNumber}
|
||||
onChange={(event) => page.setForm({ buildNumber: event.target.value })}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={Boolean(page.form.forceUpdate)}
|
||||
checkedLabel="强更"
|
||||
disabled={!canSubmit}
|
||||
label="强更"
|
||||
uncheckedLabel="普通"
|
||||
onChange={(checked) => page.setForm({ forceUpdate: checked })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="下载信息">
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
label="下载链接"
|
||||
required
|
||||
value={page.form.downloadUrl}
|
||||
onChange={(event) => page.setForm({ downloadUrl: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
label="更新描述"
|
||||
multiline
|
||||
minRows={3}
|
||||
value={page.form.description}
|
||||
onChange={(event) => page.setForm({ description: event.target.value })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function versionColumns(page) {
|
||||
const columns = [
|
||||
{
|
||||
key: "version",
|
||||
label: "版本",
|
||||
render: (item) => <Stack primary={item.version} secondary={`Build ${item.buildNumber}`} />,
|
||||
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) => <ForceBadge enabled={item.forceUpdate} />,
|
||||
width: "minmax(110px, 0.45fr)"
|
||||
},
|
||||
{
|
||||
key: "downloadUrl",
|
||||
label: "下载链接",
|
||||
render: (item) => <span className={styles.linkText}>{item.downloadUrl}</span>,
|
||||
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) => <VersionActions item={item} page={page} />,
|
||||
width: "minmax(96px, 0.42fr)"
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function VersionActions({ item, page }) {
|
||||
const deleting = page.loadingAction === `delete:${item.id}`;
|
||||
return (
|
||||
<AdminRowActions>
|
||||
{page.abilities.canUpdateVersion ? (
|
||||
<AdminActionIconButton disabled={Boolean(page.loadingAction)} label="编辑" onClick={() => page.openEdit(item)}>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canDeleteVersion ? (
|
||||
<AdminActionIconButton disabled={deleting || Boolean(page.loadingAction)} label="删除" onClick={() => page.removeVersion(item)}>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
|
||||
function Stack({ primary, secondary }) {
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<span>{primary || "-"}</span>
|
||||
<span className="muted">{secondary || "-"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ForceBadge({ enabled }) {
|
||||
const tone = enabled ? "warning" : "stopped";
|
||||
return (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{enabled ? "强更" : "普通"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function platformLabel(value) {
|
||||
if (value === "android") {
|
||||
return "Android";
|
||||
}
|
||||
if (value === "ios") {
|
||||
return "iOS";
|
||||
}
|
||||
return value || "-";
|
||||
}
|
||||
@ -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() {
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
filters={
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.keyword && !page.platform && !page.status && !page.regionId && !page.countryCode}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
@ -75,7 +73,7 @@ export function BannerConfigPage() {
|
||||
<AdminFormDialog
|
||||
loading={page.loadingAction === "create" || page.loadingAction === "edit"}
|
||||
open={Boolean(page.activeAction)}
|
||||
size="narrow"
|
||||
size="large"
|
||||
submitDisabled={
|
||||
!page.abilities.canUpdate || page.loadingAction === "create" || page.loadingAction === "edit"
|
||||
}
|
||||
@ -83,84 +81,95 @@ export function BannerConfigPage() {
|
||||
onClose={page.closeDialog}
|
||||
onSubmit={page.submitBanner}
|
||||
>
|
||||
<UploadField
|
||||
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
|
||||
label="封面图"
|
||||
value={page.form.coverUrl}
|
||||
onChange={(coverUrl) => page.setForm({ coverUrl })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="类型"
|
||||
required
|
||||
select
|
||||
value={page.form.bannerType}
|
||||
onChange={(event) => page.setForm({ bannerType: event.target.value })}
|
||||
>
|
||||
<MenuItem value="h5">H5</MenuItem>
|
||||
<MenuItem value="app">APP</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label={page.form.bannerType === "h5" ? "参数(H5链接)" : "参数"}
|
||||
required={page.form.bannerType === "h5"}
|
||||
value={page.form.param}
|
||||
onChange={(event) => page.setForm({ param: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="状态"
|
||||
required
|
||||
select
|
||||
value={page.form.status}
|
||||
onChange={(event) => page.setForm({ status: event.target.value })}
|
||||
>
|
||||
<MenuItem value="active">启用</MenuItem>
|
||||
<MenuItem value="disabled">关闭</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="平台"
|
||||
required
|
||||
select
|
||||
value={page.form.platform}
|
||||
onChange={(event) => page.setForm({ platform: event.target.value })}
|
||||
>
|
||||
<MenuItem value="android">安卓</MenuItem>
|
||||
<MenuItem value="ios">iOS</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
inputProps={{ step: 1 }}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={page.form.sortOrder}
|
||||
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
|
||||
/>
|
||||
<RegionSelect
|
||||
disabled={!page.abilities.canUpdate}
|
||||
emptyLabel="全部区域"
|
||||
loading={page.loadingRegions}
|
||||
options={page.regionOptions}
|
||||
value={page.form.regionId}
|
||||
onChange={(regionId) => page.setForm({ regionId })}
|
||||
/>
|
||||
<CountrySelect
|
||||
disabled={!page.abilities.canUpdate || page.loadingCountries}
|
||||
emptyLabel="全部国家"
|
||||
label="国家"
|
||||
options={page.countryOptions}
|
||||
value={page.form.countryCode}
|
||||
onChange={(countryCode) => page.setForm({ countryCode })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="描述"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={page.form.description}
|
||||
onChange={(event) => page.setForm({ description: event.target.value })}
|
||||
/>
|
||||
<AdminFormSection title="素材">
|
||||
<UploadField
|
||||
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
|
||||
label="封面图"
|
||||
value={page.form.coverUrl}
|
||||
onChange={(coverUrl) => page.setForm({ coverUrl })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="跳转信息">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="类型"
|
||||
required
|
||||
select
|
||||
value={page.form.bannerType}
|
||||
onChange={(event) => page.setForm({ bannerType: event.target.value })}
|
||||
>
|
||||
<MenuItem value="h5">H5</MenuItem>
|
||||
<MenuItem value="app">APP</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="平台"
|
||||
required
|
||||
select
|
||||
value={page.form.platform}
|
||||
onChange={(event) => page.setForm({ platform: event.target.value })}
|
||||
>
|
||||
<MenuItem value="android">安卓</MenuItem>
|
||||
<MenuItem value="ios">iOS</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
className={styles.formWideField}
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label={page.form.bannerType === "h5" ? "参数(H5链接)" : "参数"}
|
||||
required={page.form.bannerType === "h5"}
|
||||
value={page.form.param}
|
||||
onChange={(event) => page.setForm({ param: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="投放设置">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<RegionSelect
|
||||
disabled={!page.abilities.canUpdate}
|
||||
emptyLabel="全部区域"
|
||||
loading={page.loadingRegions}
|
||||
options={page.regionOptions}
|
||||
value={page.form.regionId}
|
||||
onChange={(regionId) => page.setForm({ regionId })}
|
||||
/>
|
||||
<CountrySelect
|
||||
disabled={!page.abilities.canUpdate || page.loadingCountries}
|
||||
emptyLabel="全部国家"
|
||||
label="国家"
|
||||
options={page.countryOptions}
|
||||
value={page.form.countryCode}
|
||||
onChange={(countryCode) => page.setForm({ countryCode })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
inputProps={{ step: 1 }}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={page.form.sortOrder}
|
||||
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={page.form.status === "active"}
|
||||
checkedLabel="启用"
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="BANNER 状态"
|
||||
switchProps={{ inputProps: { "aria-label": "BANNER 启用状态" } }}
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(checked) => page.setForm({ status: checked ? "active" : "disabled" })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="描述">
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="描述"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={page.form.description}
|
||||
onChange={(event) => page.setForm({ description: event.target.value })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
</AdminListPage>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
>
|
||||
<TextField
|
||||
autoFocus
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label={page.editingItem?.label || "H5链接"}
|
||||
value={page.form.url}
|
||||
onChange={(event) => page.setForm({ url: event.target.value })}
|
||||
/>
|
||||
<AdminFormSection title="链接信息">
|
||||
<TextField
|
||||
autoFocus
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label={page.editingItem?.label || "H5链接"}
|
||||
value={page.form.url}
|
||||
onChange={(event) => page.setForm({ url: event.target.value })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
</AdminListPage>
|
||||
);
|
||||
|
||||
@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
];
|
||||
|
||||
@ -38,3 +38,15 @@ export const appBannerSchema = z.object({
|
||||
});
|
||||
|
||||
export type AppBannerForm = z.infer<typeof appBannerSchema>;
|
||||
|
||||
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<typeof appVersionSchema>;
|
||||
|
||||
@ -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<ApiPage<App
|
||||
});
|
||||
}
|
||||
|
||||
export function listAppUserBans(query: PageQuery = {}): Promise<ApiPage<AppUserBanRecordDto>> {
|
||||
const endpoint = API_ENDPOINTS.appListBannedUsers;
|
||||
return apiRequest<ApiPage<AppUserBanRecordDto>>(apiEndpointPath(API_OPERATIONS.appListBannedUsers), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAppUser(userId: EntityId, payload: AppUserUpdatePayload): Promise<AppUserDto> {
|
||||
const endpoint = API_ENDPOINTS.appUpdateUser;
|
||||
return apiRequest<AppUserDto, AppUserUpdatePayload>(apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }), {
|
||||
|
||||
194
src/features/app-users/pages/AppUserBanListPage.jsx
Normal file
194
src/features/app-users/pages/AppUserBanListPage.jsx
Normal file
@ -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) => <TargetIdentity target={record.target} />,
|
||||
},
|
||||
{
|
||||
key: "operator",
|
||||
label: "封禁人",
|
||||
width: "minmax(280px, 1.4fr)",
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索账号、昵称、短 ID、长 ID",
|
||||
value: operatorKeyword,
|
||||
onChange: changeOperatorKeyword,
|
||||
}),
|
||||
render: (record) => <OperatorIdentity operator={record.operator} />,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(110px, 0.6fr)",
|
||||
render: (record) => <UserStatus status={record.newStatus} />,
|
||||
},
|
||||
{
|
||||
key: "reason",
|
||||
label: "原因",
|
||||
width: "minmax(190px, 1fr)",
|
||||
render: (record) => <span className="muted">{record.reason || "-"}</span>,
|
||||
},
|
||||
{
|
||||
key: "createdAtMs",
|
||||
label: "封禁时间",
|
||||
width: "190px",
|
||||
render: (record) => <span className="muted">{formatMillis(record.createdAtMs)}</span>,
|
||||
},
|
||||
],
|
||||
[changeOperatorKeyword, changeTargetKeyword, operatorKeyword, targetKeyword],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<DataState error={error} loading={loading} onRetry={reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={data.items || []}
|
||||
minWidth="1180px"
|
||||
pagination={
|
||||
data.total > 0
|
||||
? {
|
||||
page,
|
||||
pageSize: data.pageSize || pageSize,
|
||||
total: data.total,
|
||||
onPageChange: setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(record) => record.id}
|
||||
/>
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetIdentity({ target }) {
|
||||
return (
|
||||
<UserIdentity
|
||||
avatar={target?.avatar}
|
||||
name={target?.username}
|
||||
primaryFallback={target?.displayUserId || target?.userId}
|
||||
rows={[target?.displayUserId, target?.userId]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function OperatorIdentity({ operator }) {
|
||||
if (!operator) {
|
||||
return <span className="muted">-</span>;
|
||||
}
|
||||
if (operator.type === "admin") {
|
||||
return (
|
||||
<UserIdentity
|
||||
name={operator.account}
|
||||
primaryFallback={operator.adminId}
|
||||
rows={[operator.name, operator.adminId ? `Admin ID ${operator.adminId}` : "后台管理员"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (operator.type === "app_user") {
|
||||
return (
|
||||
<UserIdentity
|
||||
avatar={operator.avatar}
|
||||
name={operator.name}
|
||||
primaryFallback={operator.displayUserId || operator.userId}
|
||||
rows={[operator.displayUserId, operator.userId]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span className="muted">system</span>;
|
||||
}
|
||||
|
||||
function UserIdentity({ avatar, name, primaryFallback, rows }) {
|
||||
const title = name || primaryFallback || "-";
|
||||
const visibleRows = rows.filter(Boolean);
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.avatar}>
|
||||
{avatar ? <img src={avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
|
||||
</span>
|
||||
<div className={styles.identityText}>
|
||||
<div className={styles.nameLine}>
|
||||
<span className={styles.name}>{title}</span>
|
||||
</div>
|
||||
{visibleRows.length ? (
|
||||
<div className={styles.stack}>
|
||||
{visibleRows.map((value, index) => (
|
||||
<span className={styles.meta} key={`${value}-${index}`}>
|
||||
{value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserStatus({ status }) {
|
||||
return (
|
||||
<span className="status-badge status-badge--danger">
|
||||
<span className="status-point" />
|
||||
{appUserStatusLabels[status] || status || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<div className={styles.toolbar}>
|
||||
<section className={styles.filters}>
|
||||
<AdminFilterResetButton disabled={!page.query && !page.status} onClick={page.resetFilters} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
items={items}
|
||||
minWidth="1220px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.data.pageSize || 10,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(user) => user.userId}
|
||||
/>
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
@ -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 (
|
||||
<AdminFormDialog
|
||||
loading={loading}
|
||||
@ -291,7 +288,7 @@ function ActionModal({ children, disabled, loading, onClose, onSubmit, open, tit
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{children}
|
||||
<AdminFormSection title={sectionTitle}>{children}</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) => <UserIdentity log={log} />,
|
||||
},
|
||||
@ -188,31 +199,24 @@ export function AppUserLoginLogsPage() {
|
||||
render: (log) => <span className="muted">{formatMillis(log.createdAtMs)}</span>,
|
||||
},
|
||||
],
|
||||
[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 (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<div className={styles.toolbar}>
|
||||
<section className={styles.filters}>
|
||||
{userFilter ? (
|
||||
<FilterChip active label={`用户 ${selectedUserLabel || userFilter}`} onClick={clearUserFilter} />
|
||||
) : null}
|
||||
<AdminFilterResetButton
|
||||
disabled={!query && !result && !regionId && !userFilter}
|
||||
onClick={resetFilters}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DataState error={error} loading={loading} onRetry={reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<DataTable
|
||||
@ -222,17 +226,17 @@ export function AppUserLoginLogsPage() {
|
||||
minWidth="1380px"
|
||||
getRowProps={(log) => 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 ? (
|
||||
<PaginationBar
|
||||
page={page}
|
||||
pageSize={data.pageSize || pageSize}
|
||||
total={data.total}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@ -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() {
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable columns={columns} items={items} minWidth="1340px" rowKey={(task) => task.taskId} />
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
minWidth="1340px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.data.pageSize || 10,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(task) => task.taskId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<TaskFormDialog
|
||||
@ -218,7 +229,7 @@ function TaskStatusSwitch({ page, task }) {
|
||||
!page.abilities.canStatus || task.status === "archived" || page.loadingAction === `status-${task.taskId}`;
|
||||
return (
|
||||
<div className={styles.statusCell}>
|
||||
<Switch
|
||||
<AdminSwitch
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
inputProps={{ "aria-label": checked ? "暂停任务" : "启用任务" }}
|
||||
@ -254,111 +265,118 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="任务类型"
|
||||
required
|
||||
select
|
||||
value={form.taskType}
|
||||
onChange={(event) => page.setForm({ ...form, taskType: event.target.value })}
|
||||
>
|
||||
{taskTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="状态"
|
||||
required
|
||||
select
|
||||
value={form.status}
|
||||
onChange={(event) => page.setForm({ ...form, status: event.target.value })}
|
||||
>
|
||||
{taskStatusOptions
|
||||
.filter(([value]) => value)
|
||||
.map(([value, label]) => (
|
||||
<AdminFormSection title="基础信息">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="任务类型"
|
||||
required
|
||||
select
|
||||
value={form.taskType}
|
||||
onChange={(event) => page.setForm({ ...form, taskType: event.target.value })}
|
||||
>
|
||||
{taskTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="任务名称"
|
||||
required
|
||||
value={form.title}
|
||||
onChange={(event) => page.setForm({ ...form, title: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="任务分类"
|
||||
required
|
||||
value={form.category}
|
||||
onChange={(event) => page.setForm({ ...form, category: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="指标类型"
|
||||
required
|
||||
value={form.metricType}
|
||||
onChange={(event) => page.setForm({ ...form, metricType: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="目标单位"
|
||||
required
|
||||
select
|
||||
value={form.targetUnit}
|
||||
onChange={(event) => page.setForm({ ...form, targetUnit: event.target.value })}
|
||||
>
|
||||
{taskUnitOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="目标值"
|
||||
required
|
||||
type="number"
|
||||
value={form.targetValue}
|
||||
onChange={(event) => page.setForm({ ...form, targetValue: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="奖励金币"
|
||||
required
|
||||
type="number"
|
||||
value={form.rewardCoinAmount}
|
||||
onChange={(event) => page.setForm({ ...form, rewardCoinAmount: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="有效开始时间"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="datetime-local"
|
||||
value={form.effectiveFrom}
|
||||
onChange={(event) => page.setForm({ ...form, effectiveFrom: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="有效结束时间"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="datetime-local"
|
||||
value={form.effectiveTo}
|
||||
onChange={(event) => page.setForm({ ...form, effectiveTo: event.target.value })}
|
||||
/>
|
||||
</TextField>
|
||||
<AdminFormSwitchField
|
||||
checked={form.status === "active"}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled || form.status === "archived"}
|
||||
label="任务状态"
|
||||
switchProps={{ inputProps: { "aria-label": "任务启用状态" } }}
|
||||
uncheckedLabel={taskSwitchUncheckedLabel(form.status, mode)}
|
||||
onChange={(checked) =>
|
||||
page.setForm({ ...form, status: nextTaskStatusFromSwitch(form.status, checked, mode) })
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="任务名称"
|
||||
required
|
||||
value={form.title}
|
||||
onChange={(event) => page.setForm({ ...form, title: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="任务分类"
|
||||
required
|
||||
value={form.category}
|
||||
onChange={(event) => page.setForm({ ...form, category: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="目标与奖励">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="指标类型"
|
||||
required
|
||||
value={form.metricType}
|
||||
onChange={(event) => page.setForm({ ...form, metricType: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="目标单位"
|
||||
required
|
||||
select
|
||||
value={form.targetUnit}
|
||||
onChange={(event) => page.setForm({ ...form, targetUnit: event.target.value })}
|
||||
>
|
||||
{taskUnitOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="目标值"
|
||||
required
|
||||
type="number"
|
||||
value={form.targetValue}
|
||||
onChange={(event) => page.setForm({ ...form, targetValue: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="奖励金币"
|
||||
required
|
||||
type="number"
|
||||
value={form.rewardCoinAmount}
|
||||
onChange={(event) => page.setForm({ ...form, rewardCoinAmount: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="有效期">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="有效开始时间"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="datetime-local"
|
||||
value={form.effectiveFrom}
|
||||
onChange={(event) => page.setForm({ ...form, effectiveFrom: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="有效结束时间"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="datetime-local"
|
||||
value={form.effectiveTo}
|
||||
onChange={(event) => page.setForm({ ...form, effectiveTo: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="说明">
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="任务描述"
|
||||
@ -367,7 +385,7 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
||||
value={form.description}
|
||||
onChange={(event) => page.setForm({ ...form, description: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
@ -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 || "";
|
||||
}
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
export function DashboardAlerts({ alerts = [] }) {
|
||||
return (
|
||||
<section className="alert-strip" aria-label="实时告警">
|
||||
{alerts.map((alert) => (
|
||||
<div className="alert-item" key={`${alert.name}-${alert.time}`}>
|
||||
<span className={`alert-dot alert-dot--${alert.type}`} />
|
||||
<div>
|
||||
<div className="alert-name">{alert.name}</div>
|
||||
<div className="alert-desc">{alert.desc}</div>
|
||||
</div>
|
||||
<div className="alert-time">{alert.time}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -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 }) {
|
||||
<section className="overview-charts" aria-label="资源图表">
|
||||
<ChartCard title="后台用户" colorToken="--chart-blue" series={overview?.series?.users || []} />
|
||||
<ChartCard title="操作日志" colorToken="--chart-green" series={overview?.series?.operations || []} />
|
||||
<Card className="chart-card">
|
||||
<div className="card-head">
|
||||
<div className="card-title">通知趋势</div>
|
||||
</div>
|
||||
<BarChart series={overview?.series?.notifications || []} />
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 }) {
|
||||
<KpiCard icon={DnsOutlined} label="后台用户" value={overview?.usersTotal || 0} unit="个" sub={<span className="status-ok">启用 {stats.activeUsers}</span>} />
|
||||
<KpiCard icon={DeveloperBoardOutlined} label="角色数量" value={stats.rolesTotal} unit="个" sub={`菜单 ${stats.menuTotal}`} />
|
||||
<KpiCard icon={StorageOutlined} label="今日操作" value={stats.logsToday} unit="条" tone="info" sub="操作日志" />
|
||||
<KpiCard icon={SecurityOutlined} label="未读通知" value={stats.unreadNotifications} unit="条" tone="warning" sub={<><span className="status-warn">锁定 {stats.lockedUsers}</span> / 停用 {stats.disabledUsers}</>} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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() {
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<DashboardStats overview={page.overview} stats={page.stats} />
|
||||
<DashboardCharts overview={page.overview} />
|
||||
|
||||
<DashboardAlerts alerts={page.overview?.alerts || []} />
|
||||
</DataState>
|
||||
</>
|
||||
);
|
||||
|
||||
155
src/features/games/api.ts
Normal file
155
src/features/games/api.ts
Normal file
@ -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<GamePlatformDto, "appCode" | "createdAtMs" | "updatedAtMs">;
|
||||
export type GameCatalogPayload = Omit<GameCatalogDto, "appCode" | "createdAtMs" | "updatedAtMs">;
|
||||
|
||||
export interface GameCatalogQuery {
|
||||
platformCode?: string;
|
||||
status?: string;
|
||||
pageSize?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export function listGamePlatforms(status = ""): Promise<GamePlatformPage> {
|
||||
const endpoint = API_ENDPOINTS.listPlatforms;
|
||||
return apiRequest<GamePlatformPage>(apiEndpointPath(API_OPERATIONS.listPlatforms), {
|
||||
method: endpoint.method,
|
||||
query: { status },
|
||||
}).then((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map(normalizePlatform),
|
||||
}));
|
||||
}
|
||||
|
||||
export function upsertGamePlatform(payload: GamePlatformPayload, platformCode = ""): Promise<GamePlatformDto> {
|
||||
const operation = platformCode ? API_OPERATIONS.updatePlatform : API_OPERATIONS.createPlatform;
|
||||
const endpoint = API_ENDPOINTS[operation];
|
||||
return apiRequest<GamePlatformDto, GamePlatformPayload>(
|
||||
apiEndpointPath(operation, platformCode ? { platform_code: platformCode } : {}),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
).then(normalizePlatform);
|
||||
}
|
||||
|
||||
export function listGameCatalog(query: GameCatalogQuery = {}): Promise<GameCatalogPage> {
|
||||
const endpoint = API_ENDPOINTS.listCatalog;
|
||||
return apiRequest<GameCatalogPage>(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<GameCatalogDto> {
|
||||
const operation = gameId ? API_OPERATIONS.updateCatalog : API_OPERATIONS.createCatalog;
|
||||
const endpoint = API_ENDPOINTS[operation];
|
||||
return apiRequest<GameCatalogDto, GameCatalogPayload>(
|
||||
apiEndpointPath(operation, gameId ? { game_id: gameId } : {}),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
).then(normalizeCatalog);
|
||||
}
|
||||
|
||||
export function setGameStatus(gameId: string, status: string): Promise<GameCatalogDto> {
|
||||
const endpoint = API_ENDPOINTS.setGameStatus;
|
||||
return apiRequest<GameCatalogDto, { status: string }>(
|
||||
apiEndpointPath(API_OPERATIONS.setGameStatus, { game_id: gameId }),
|
||||
{
|
||||
body: { status },
|
||||
method: endpoint.method,
|
||||
},
|
||||
).then(normalizeCatalog);
|
||||
}
|
||||
|
||||
function normalizePlatform(platform: Partial<GamePlatformDto>): 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>): 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;
|
||||
}
|
||||
57
src/features/games/games.module.css
Normal file
57
src/features/games/games.module.css
Normal file
@ -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;
|
||||
}
|
||||
303
src/features/games/hooks/useGamesPage.js
Normal file
303
src/features/games/hooks/useGamesPage.js
Normal file
@ -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),
|
||||
};
|
||||
}
|
||||
440
src/features/games/pages/GameListPage.jsx
Normal file
440
src/features/games/pages/GameListPage.jsx
Normal file
@ -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) => <GameIdentity game={game} />,
|
||||
},
|
||||
{
|
||||
key: "platform",
|
||||
label: "平台",
|
||||
width: "minmax(160px, 0.75fr)",
|
||||
render: (game) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{game.platformCode}</span>
|
||||
<span className={styles.meta}>{game.providerGameId}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "launch",
|
||||
label: "启动",
|
||||
width: "minmax(150px, 0.75fr)",
|
||||
render: (game) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{game.launchMode}</span>
|
||||
<span className={styles.meta}>{game.orientation}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => <GameStatusSwitch game={game} page={page} />,
|
||||
};
|
||||
}
|
||||
if (column.key === "actions") {
|
||||
return {
|
||||
...column,
|
||||
render: (game) => <GameRowActions game={game} page={page} />,
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
<>
|
||||
{page.abilities.canUpdate ? (
|
||||
<AdminActionIconButton label="添加平台" onClick={page.openCreatePlatform}>
|
||||
<SportsEsportsOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canCreate ? (
|
||||
<AdminActionIconButton label="添加游戏" primary onClick={page.openCreateGame}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
minWidth="1220px"
|
||||
pagination={{
|
||||
hasNextPage: Boolean(page.data.nextCursor),
|
||||
itemCount: items.length,
|
||||
onPageChange: page.changePage,
|
||||
onPageSizeChange: page.changePageSize,
|
||||
page: page.page,
|
||||
pageSize: page.pageSize,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
}}
|
||||
rowKey={(game) => game.gameId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<GameFormDialog
|
||||
form={page.gameForm}
|
||||
loading={page.loadingAction === page.activeAction}
|
||||
mode={page.activeAction === "edit-game" ? "edit" : "create"}
|
||||
open={page.activeAction === "create-game" || page.activeAction === "edit-game"}
|
||||
page={page}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitGame}
|
||||
/>
|
||||
<PlatformFormDialog
|
||||
form={page.platformForm}
|
||||
loading={page.loadingAction === page.activeAction}
|
||||
mode={page.activeAction === "edit-platform" ? "edit" : "create"}
|
||||
open={page.activeAction === "create-platform" || page.activeAction === "edit-platform"}
|
||||
page={page}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitPlatform}
|
||||
/>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function GameIdentity({ game }) {
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
{game.iconUrl ? (
|
||||
<img alt="" className={styles.icon} src={game.iconUrl} />
|
||||
) : (
|
||||
<span className={styles.icon} />
|
||||
)}
|
||||
<div className={styles.identityText}>
|
||||
<span className={styles.name}>{game.gameName || game.gameId}</span>
|
||||
<span className={styles.meta}>{game.gameId}</span>
|
||||
<span className={styles.tags}>{(game.tags || []).slice(0, 3).join(" / ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GameStatusSwitch({ page, game }) {
|
||||
const disabled =
|
||||
!page.abilities.canStatus || game.status === "disabled" || page.loadingAction === `status-${game.gameId}`;
|
||||
return (
|
||||
<AdminSwitch
|
||||
checked={game.status === "active"}
|
||||
disabled={disabled}
|
||||
label={`${game.gameName || game.gameId} 状态`}
|
||||
size="small"
|
||||
onChange={(event) => page.changeGameStatus(game, event.target.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GameRowActions({ page, game }) {
|
||||
return (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="编辑游戏"
|
||||
onClick={() => page.openEditGame(game)}
|
||||
>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
|
||||
function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
|
||||
const disabled = mode === "edit" ? !page.abilities.canUpdate : !page.abilities.canCreate;
|
||||
return (
|
||||
<AdminFormDialog
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="wide"
|
||||
submitLabel={mode === "edit" ? "保存游戏" : "创建游戏"}
|
||||
title={mode === "edit" ? "编辑游戏" : "添加游戏"}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<AdminFormSection title="基础信息">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={mode === "edit"}
|
||||
label="游戏 ID"
|
||||
required
|
||||
value={form.gameId}
|
||||
onChange={(event) => page.setGameForm({ ...form, gameId: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="平台"
|
||||
required
|
||||
select={page.platformOptions.length > 0}
|
||||
value={form.platformCode}
|
||||
onChange={(event) => page.setGameForm({ ...form, platformCode: event.target.value })}
|
||||
>
|
||||
{page.platformOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label || value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="平台游戏 ID"
|
||||
required
|
||||
value={form.providerGameId}
|
||||
onChange={(event) => page.setGameForm({ ...form, providerGameId: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="游戏名称"
|
||||
required
|
||||
value={form.gameName}
|
||||
onChange={(event) => page.setGameForm({ ...form, gameName: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="类目"
|
||||
value={form.category}
|
||||
onChange={(event) => page.setGameForm({ ...form, category: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="启动与状态">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
label="启动模式"
|
||||
select
|
||||
value={form.launchMode}
|
||||
onChange={(event) => page.setGameForm({ ...form, launchMode: event.target.value })}
|
||||
>
|
||||
{launchModeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="方向"
|
||||
select
|
||||
value={form.orientation}
|
||||
onChange={(event) => page.setGameForm({ ...form, orientation: event.target.value })}
|
||||
>
|
||||
{orientationOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="最低金币"
|
||||
type="number"
|
||||
value={form.minCoin}
|
||||
onChange={(event) => page.setGameForm({ ...form, minCoin: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={(event) => page.setGameForm({ ...form, sortOrder: event.target.value })}
|
||||
/>
|
||||
<SwitchOnlyField
|
||||
checked={form.status === "active"}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label="游戏启用状态"
|
||||
uncheckedLabel="维护"
|
||||
onChange={(checked) =>
|
||||
page.setGameForm({
|
||||
...form,
|
||||
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="素材与标签">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<UploadField
|
||||
disabled={disabled || !page.abilities.canUpload}
|
||||
label="图标"
|
||||
value={form.iconUrl}
|
||||
onChange={(iconUrl) => page.setGameForm({ ...form, iconUrl })}
|
||||
/>
|
||||
<UploadField
|
||||
disabled={disabled || !page.abilities.canUpload}
|
||||
label="封面"
|
||||
value={form.coverUrl}
|
||||
onChange={(coverUrl) => page.setGameForm({ ...form, coverUrl })}
|
||||
/>
|
||||
<TextField
|
||||
className={styles.fullField}
|
||||
label="标签"
|
||||
value={form.tagsText}
|
||||
onChange={(event) => page.setGameForm({ ...form, tagsText: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
|
||||
return (
|
||||
<AdminFormDialog
|
||||
disabled={!page.abilities.canUpdate}
|
||||
loading={loading}
|
||||
open={open}
|
||||
submitLabel={mode === "edit" ? "保存平台" : "创建平台"}
|
||||
title={mode === "edit" ? "编辑平台" : "添加平台"}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<AdminFormSection title="平台信息">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
disabled={mode === "edit"}
|
||||
label="平台 Code"
|
||||
required
|
||||
value={form.platformCode}
|
||||
onChange={(event) => page.setPlatformForm({ ...form, platformCode: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="平台名称"
|
||||
required
|
||||
value={form.platformName}
|
||||
onChange={(event) => page.setPlatformForm({ ...form, platformName: event.target.value })}
|
||||
/>
|
||||
<SwitchOnlyField
|
||||
checked={form.status === "active"}
|
||||
checkedLabel="启用"
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="游戏平台启用状态"
|
||||
uncheckedLabel="维护"
|
||||
onChange={(checked) =>
|
||||
page.setPlatformForm({
|
||||
...form,
|
||||
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={(event) => page.setPlatformForm({ ...form, sortOrder: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="接入配置">
|
||||
<TextField
|
||||
className={styles.fullField}
|
||||
label="H5 Base URL"
|
||||
required
|
||||
value={form.apiBaseUrl}
|
||||
onChange={(event) => page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchOnlyField({ checked, checkedLabel, disabled, label, onChange, uncheckedLabel }) {
|
||||
return (
|
||||
<span className={styles.switchOnlyField}>
|
||||
<AdminSwitch
|
||||
checked={checked}
|
||||
checkedLabel={checkedLabel}
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
uncheckedLabel={uncheckedLabel}
|
||||
onChange={(event) => onChange(event.target.checked, event)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString();
|
||||
}
|
||||
14
src/features/games/permissions.js
Normal file
14
src/features/games/permissions.js
Normal file
@ -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),
|
||||
};
|
||||
}
|
||||
12
src/features/games/routes.js
Normal file
12
src/features/games/routes.js
Normal file
@ -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,
|
||||
},
|
||||
];
|
||||
@ -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 ? <AdminFormSection title={sectionTitle}>{children}</AdminFormSection> : children}
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className={styles.filters}>
|
||||
{onReset ? <AdminFilterResetButton disabled={!hasActiveFilters} onClick={onReset} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.toolbarLeft}>
|
||||
<HostOrgFilters {...filters} />
|
||||
<div className={styles.toolbarActions}>
|
||||
{actions.map((action) => (
|
||||
<Tooltip arrow key={action.label} title={action.label}>
|
||||
<span>
|
||||
<IconButton
|
||||
className={styles.toolbarAction}
|
||||
disabled={action.disabled}
|
||||
label={action.label}
|
||||
tone={action.tone}
|
||||
onClick={action.onClick}
|
||||
sx={action.variant === "primary" ? primaryActionSx : undefined}
|
||||
>
|
||||
{action.icon}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{actions.length ? (
|
||||
<div className={styles.toolbarActions}>
|
||||
{actions.map((action) => (
|
||||
<Tooltip arrow key={action.label} title={action.label}>
|
||||
<span>
|
||||
<IconButton
|
||||
className={styles.toolbarAction}
|
||||
disabled={action.disabled}
|
||||
label={action.label}
|
||||
tone={action.tone}
|
||||
onClick={action.onClick}
|
||||
sx={action.variant === "primary" ? primaryActionSx : undefined}
|
||||
>
|
||||
{action.icon}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
123
src/features/host-org/hooks/useHostManagersPage.js
Normal file
123
src/features/host-org/hooks/useHostManagersPage.js
Normal file
@ -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()}`;
|
||||
}
|
||||
@ -63,6 +63,7 @@
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.filters {
|
||||
|
||||
@ -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) => <HostOrgStatus value={item.status} />,
|
||||
render: (item, _index, context) => <AgencyStatusSwitch item={item} page={context?.page} />,
|
||||
width: "minmax(90px, 0.7fr)",
|
||||
},
|
||||
{
|
||||
@ -129,46 +126,27 @@ export function HostAgenciesPage() {
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
actions={toolbarActions}
|
||||
filters={{
|
||||
extraFilters: [
|
||||
{
|
||||
label: "上级 BD 用户 ID",
|
||||
name: "parentBdUserId",
|
||||
onChange: page.changeParentBdUserId,
|
||||
value: page.parentBdUserId,
|
||||
},
|
||||
],
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
regionId: page.regionId,
|
||||
regionOptions: page.regionOptions,
|
||||
searchPlaceholder: "搜索 Agency、Owner、用户...",
|
||||
status: page.status,
|
||||
statusOptions: agencyStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onReset: page.resetFilters,
|
||||
onStatusChange: page.changeStatus,
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={columns}
|
||||
context={{ regionOptions: page.regionOptions }}
|
||||
context={{ page, regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1250px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.data.pageSize || 10,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(item) => item.agencyId}
|
||||
/>
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
@ -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 })}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={page.agencyForm.joinEnabled}
|
||||
disabled={createDisabled}
|
||||
onChange={(event) =>
|
||||
page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })
|
||||
}
|
||||
/>
|
||||
<AdminSwitch
|
||||
checked={page.agencyForm.joinEnabled}
|
||||
checkedLabel="允许"
|
||||
disabled={createDisabled}
|
||||
label="入会状态"
|
||||
uncheckedLabel="禁止"
|
||||
onChange={(event) =>
|
||||
page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })
|
||||
}
|
||||
label={page.agencyForm.joinEnabled ? "允许入会" : "禁止入会"}
|
||||
/>
|
||||
<TextField
|
||||
disabled={createDisabled}
|
||||
@ -237,6 +214,7 @@ export function HostAgenciesPage() {
|
||||
disabled={statusDisabled}
|
||||
loading={page.loadingAction === "agency-join"}
|
||||
open={page.activeAction === "agency-join"}
|
||||
sectionTitle="入会设置"
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitJoinEnabled}
|
||||
title="入会开关"
|
||||
@ -251,17 +229,15 @@ export function HostAgenciesPage() {
|
||||
page.setJoinEnabledForm({ ...page.joinEnabledForm, agencyId: event.target.value })
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={page.joinEnabledForm.joinEnabled}
|
||||
disabled={statusDisabled}
|
||||
onChange={(event) =>
|
||||
page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked })
|
||||
}
|
||||
/>
|
||||
<AdminSwitch
|
||||
checked={page.joinEnabledForm.joinEnabled}
|
||||
checkedLabel="允许"
|
||||
disabled={statusDisabled}
|
||||
label="入会状态"
|
||||
uncheckedLabel="禁止"
|
||||
onChange={(event) =>
|
||||
page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked })
|
||||
}
|
||||
label={page.joinEnabledForm.joinEnabled ? "允许入会" : "禁止入会"}
|
||||
/>
|
||||
<TextField
|
||||
disabled={statusDisabled}
|
||||
@ -279,6 +255,7 @@ export function HostAgenciesPage() {
|
||||
disabled={statusDisabled}
|
||||
loading={page.loadingAction === "agency-close"}
|
||||
open={page.activeAction === "agency-close"}
|
||||
sectionTitle="关闭信息"
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitClose}
|
||||
submitLabel="确认关闭"
|
||||
@ -328,3 +305,19 @@ function ParentBD({ item }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AgencyStatusSwitch({ item, page }) {
|
||||
const active = item.status === "active";
|
||||
return (
|
||||
<AdminSwitch
|
||||
checked={active}
|
||||
disabled={!active || !page?.abilities.canStatus || page.loadingAction === `agency-status-${item.agencyId}`}
|
||||
inputProps={{ "aria-label": active ? "关闭 Agency" : "Agency 已关闭" }}
|
||||
onChange={(event) => {
|
||||
if (!event.target.checked) {
|
||||
page.closeAgencyFromList(item);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
actions={toolbarActions}
|
||||
filters={{
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
regionId: page.regionId,
|
||||
regionOptions: page.regionOptions,
|
||||
searchPlaceholder: "搜索用户 ID、展示 ID、用户名...",
|
||||
status: page.status,
|
||||
statusOptions: bdStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onReset: page.resetFilters,
|
||||
onStatusChange: page.changeStatus,
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
@ -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 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
@ -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 (
|
||||
<Switch
|
||||
<AdminSwitch
|
||||
checked={item.status === "active"}
|
||||
disabled={!page?.abilities.canUpdate || page.loadingAction === `bd-leader-status-${item.userId}`}
|
||||
inputProps={{ "aria-label": item.status === "active" ? "停用 BD Leader" : "启用 BD Leader" }}
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import SyncAltOutlined from "@mui/icons-material/SyncAltOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
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 { bdStatusFilters } from "@/features/host-org/constants.js";
|
||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||
import { 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 { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
|
||||
@ -38,7 +35,7 @@ const bdColumns = [
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (item) => <HostOrgStatus value={item.status} />,
|
||||
render: (item, _index, context) => <BDStatusSwitch item={item} page={context?.page} />,
|
||||
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: <SyncAltOutlined fontSize="small" />, label: "BD 状态", onClick: page.openBDStatusForm }
|
||||
: null,
|
||||
page.abilities.canCreate
|
||||
? { icon: <Add fontSize="small" />, label: "创建 BD", onClick: page.openBDForm, variant: "primary" }
|
||||
: null,
|
||||
@ -115,46 +108,27 @@ export function HostBdsPage() {
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
actions={toolbarActions}
|
||||
filters={{
|
||||
extraFilters: [
|
||||
{
|
||||
label: "上级 Leader 用户 ID",
|
||||
name: "parentLeaderUserId",
|
||||
onChange: page.changeParentLeaderUserId,
|
||||
value: page.parentLeaderUserId,
|
||||
},
|
||||
],
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
regionId: page.regionId,
|
||||
regionOptions: page.regionOptions,
|
||||
searchPlaceholder: "搜索用户 ID、展示 ID、用户名...",
|
||||
status: page.status,
|
||||
statusOptions: bdStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onReset: page.resetFilters,
|
||||
onStatusChange: page.changeStatus,
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={columns}
|
||||
context={{ regionOptions: page.regionOptions }}
|
||||
context={{ page, regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1260px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.data.pageSize || 10,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(item) => `bd-${item.userId}`}
|
||||
/>
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
@ -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 })}
|
||||
/>
|
||||
</HostOrgActionModal>
|
||||
|
||||
<HostOrgActionModal
|
||||
disabled={updateDisabled}
|
||||
loading={page.loadingAction === "bd-status"}
|
||||
open={page.activeAction === "bd-status"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitBDStatus}
|
||||
title="BD 状态"
|
||||
>
|
||||
<TextField
|
||||
disabled={updateDisabled}
|
||||
label="用户 ID"
|
||||
required
|
||||
type="number"
|
||||
value={page.bdStatusForm.targetUserId}
|
||||
onChange={(event) =>
|
||||
page.setBDStatusForm({ ...page.bdStatusForm, targetUserId: event.target.value })
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
disabled={updateDisabled}
|
||||
label="状态"
|
||||
required
|
||||
select
|
||||
value={page.bdStatusForm.status}
|
||||
onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, status: event.target.value })}
|
||||
>
|
||||
<MenuItem value="active">启用</MenuItem>
|
||||
<MenuItem value="disabled">停用</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={updateDisabled}
|
||||
label="原因"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={page.bdStatusForm.reason}
|
||||
onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, reason: event.target.value })}
|
||||
/>
|
||||
</HostOrgActionModal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -260,6 +196,17 @@ function ParentLeader({ item }) {
|
||||
);
|
||||
}
|
||||
|
||||
function BDStatusSwitch({ item, page }) {
|
||||
return (
|
||||
<AdminSwitch
|
||||
checked={item.status === "active"}
|
||||
disabled={!page?.abilities.canUpdate || page.loadingAction === `bd-status-${item.userId}`}
|
||||
inputProps={{ "aria-label": item.status === "active" ? "停用 BD" : "启用 BD" }}
|
||||
onChange={(event) => page.toggleBD(item, event.target.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Creator({ item }) {
|
||||
if (!item.createdByUserId && !item.createdByDisplayUserId && !item.createdByUsername) {
|
||||
return "-";
|
||||
|
||||
@ -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() {
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
actions={toolbarActions}
|
||||
filters={{
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
regionId: page.regionId,
|
||||
regionOptions: page.regionOptions,
|
||||
searchPlaceholder: "搜索用户 ID、展示 ID、用户名...",
|
||||
status: page.status,
|
||||
statusOptions: coinSellerStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onReset: page.resetFilters,
|
||||
onStatusChange: page.changeStatus,
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
@ -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 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
@ -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 (
|
||||
<Switch
|
||||
<AdminSwitch
|
||||
checked={item.status === "active"}
|
||||
disabled={!page.abilities.canUpdate || page.loadingAction === `coin-seller-status-${item.userId}`}
|
||||
inputProps={{ "aria-label": item.status === "active" ? "停用币商" : "启用币商" }}
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import { 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 {
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
@ -88,9 +86,6 @@ export function HostCountriesPage() {
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<AdminFilterResetButton disabled={!page.query && !page.enabledStatus} onClick={page.resetFilters} />
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreate ? (
|
||||
<AdminActionIconButton label="创建国家" primary onClick={page.openCreateCountry}>
|
||||
@ -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" ? (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={page.countryForm.enabled}
|
||||
disabled={createDisabled}
|
||||
onChange={(event) =>
|
||||
page.setCountryForm({ ...page.countryForm, enabled: event.target.checked })
|
||||
}
|
||||
/>
|
||||
<AdminSwitch
|
||||
checked={page.countryForm.enabled}
|
||||
checkedLabel="启用"
|
||||
disabled={createDisabled}
|
||||
label="国家状态"
|
||||
uncheckedLabel="停用"
|
||||
onChange={(event) =>
|
||||
page.setCountryForm({ ...page.countryForm, enabled: event.target.checked })
|
||||
}
|
||||
label={page.countryForm.enabled ? "启用" : "停用"}
|
||||
/>
|
||||
) : null}
|
||||
</HostOrgActionModal>
|
||||
@ -206,7 +200,7 @@ function CountryActions({ item, page }) {
|
||||
|
||||
function CountryStatusSwitch({ item, page }) {
|
||||
return (
|
||||
<Switch
|
||||
<AdminSwitch
|
||||
checked={Boolean(item.enabled)}
|
||||
disabled={!page.abilities.canStatus || page.loadingAction === `country-status-${item.countryId}`}
|
||||
inputProps={{ "aria-label": item.enabled ? "禁用国家" : "启用国家" }}
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js";
|
||||
import { 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 { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import styles from "@/features/host-org/host-org.module.css";
|
||||
@ -20,7 +18,7 @@ const hostColumns = [
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (item) => <HostOrgStatus value={item.status} />,
|
||||
render: (item) => <HostStatusSwitch item={item} />,
|
||||
width: "minmax(90px, 0.7fr)",
|
||||
},
|
||||
{
|
||||
@ -110,30 +108,6 @@ export function HostHostsPage() {
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
filters={{
|
||||
extraFilters: [
|
||||
{
|
||||
label: "Agency ID",
|
||||
name: "agencyId",
|
||||
onChange: page.changeAgencyId,
|
||||
value: page.agencyId,
|
||||
},
|
||||
],
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
regionId: page.regionId,
|
||||
regionOptions: page.regionOptions,
|
||||
searchPlaceholder: "搜索用户 ID、展示 ID、用户名、Agency...",
|
||||
status: page.status,
|
||||
statusOptions: hostStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onReset: page.resetFilters,
|
||||
onStatusChange: page.changeStatus,
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
@ -141,16 +115,18 @@ export function HostHostsPage() {
|
||||
context={{ regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1240px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.data.pageSize || 10,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(item) => item.userId}
|
||||
/>
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
@ -168,3 +144,13 @@ function HostUser({ item }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HostStatusSwitch({ item }) {
|
||||
return (
|
||||
<AdminSwitch
|
||||
checked={item.status === "active"}
|
||||
disabled
|
||||
inputProps={{ "aria-label": item.status === "active" ? "主播已启用" : "主播已停用" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
168
src/features/host-org/pages/HostManagersPage.jsx
Normal file
168
src/features/host-org/pages/HostManagersPage.jsx
Normal file
@ -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) => <ManagerUser item={item} />,
|
||||
width: "minmax(220px, 1.25fr)",
|
||||
},
|
||||
{
|
||||
key: "agency",
|
||||
label: "Agency",
|
||||
render: (item) => <HostOrgEntity id={item.agencyId} name={item.name} prefix="Agency " />,
|
||||
width: "minmax(190px, 1fr)",
|
||||
},
|
||||
{
|
||||
key: "parentBdUserId",
|
||||
label: "上级 BD",
|
||||
render: (item) => <ParentBD item={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) => <ManagerStatusSwitch item={item} page={context?.page} />,
|
||||
width: "minmax(90px, 0.7fr)",
|
||||
},
|
||||
{
|
||||
key: "joinEnabled",
|
||||
label: "入会",
|
||||
render: (item) => (
|
||||
<span className={item.joinEnabled ? styles.positive : styles.negative}>
|
||||
{item.joinEnabled ? "允许" : "禁止"}
|
||||
</span>
|
||||
),
|
||||
width: "minmax(80px, 0.6fr)",
|
||||
},
|
||||
{ key: "maxHosts", label: "最大主播数", width: "minmax(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 (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={columns}
|
||||
context={{ page, regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1250px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.data.pageSize || 10,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(item) => `manager-${item.ownerUserId || item.agencyId}`}
|
||||
/>
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagerUser({ item }) {
|
||||
return (
|
||||
<HostOrgPerson
|
||||
avatar={item.ownerAvatar}
|
||||
displayUserId={item.ownerDisplayUserId}
|
||||
fallbackId={item.ownerUserId}
|
||||
username={item.ownerUsername}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ParentBD({ item }) {
|
||||
if (!item.parentBdUserId) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<HostOrgPerson
|
||||
avatar={item.parentBdAvatar}
|
||||
displayUserId={item.parentBdDisplayUserId}
|
||||
fallbackId={item.parentBdUserId}
|
||||
username={item.parentBdUsername || "BD"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagerStatusSwitch({ item, page }) {
|
||||
const active = item.status === "active";
|
||||
return (
|
||||
<AdminSwitch
|
||||
checked={active}
|
||||
disabled={!active || !page?.abilities.canStatus || page.loadingAction === `manager-status-${item.agencyId}`}
|
||||
inputProps={{ "aria-label": active ? "停用经理" : "经理已停用" }}
|
||||
onChange={(event) => {
|
||||
if (!event.target.checked) {
|
||||
page.closeManagerAgency(item);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.toolbarLeft}>
|
||||
<section className={styles.filters}>
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.query && !page.status}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{page.abilities.canCreate ? (
|
||||
{page.abilities.canCreate ? (
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.toolbarActions}>
|
||||
<Tooltip arrow title="创建区域">
|
||||
<span>
|
||||
@ -101,8 +91,8 @@ export function HostRegionsPage() {
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
@ -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 (
|
||||
<Switch
|
||||
<AdminSwitch
|
||||
checked={checked}
|
||||
disabled={isGlobal || !page.abilities.canStatus || page.loadingAction === `region-status-${item.regionId}`}
|
||||
inputProps={{ "aria-label": checked ? "停用区域" : "启用区域" }}
|
||||
|
||||
@ -18,7 +18,15 @@ export const hostOrgRoutes = [
|
||||
permission: PERMISSIONS.regionView
|
||||
},
|
||||
{
|
||||
label: "Agency 管理",
|
||||
label: "经理列表",
|
||||
loader: () => 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",
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||
|
||||
export function LogFilters({ onReset, query }) {
|
||||
return (
|
||||
<section className="filters-panel">
|
||||
<AdminFilterResetButton disabled={!query} onClick={onReset} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<LogToolbar canExport={page.abilities.canExport} isLogin={page.isLogin} onExport={page.downloadLogs} />
|
||||
<LogFilters query={page.query} onReset={page.resetFilters} />
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<LogTable data={page.data} isLogin={page.isLogin} query={page.query} onQueryChange={page.changeQuery} />
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={page.data.total || 0}
|
||||
onPageChange={page.setPage}
|
||||
<LogTable
|
||||
data={page.data}
|
||||
isLogin={page.isLogin}
|
||||
pagination={{
|
||||
page: page.page,
|
||||
pageSize: page.data.pageSize || 10,
|
||||
total: page.data.total || 0,
|
||||
onPageChange: page.setPage,
|
||||
}}
|
||||
query={page.query}
|
||||
onQueryChange={page.changeQuery}
|
||||
/>
|
||||
</DataState>
|
||||
</>
|
||||
|
||||
@ -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
|
||||
<Drawer anchor="right" open={open} onClose={onClose}>
|
||||
<form className="form-drawer" onSubmit={onSubmit}>
|
||||
<h2>{editingMenu ? "编辑菜单" : "新增菜单"}</h2>
|
||||
<TextField label="菜单名称" required value={form.label} onChange={(event) => setForm({ ...form, label: event.target.value })} />
|
||||
<TextField label="菜单编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
|
||||
<TextField label="父级 ID" type="number" value={form.parentId} onChange={(event) => setForm({ ...form, parentId: event.target.value })} />
|
||||
<TextField label="路由" value={form.path} onChange={(event) => setForm({ ...form, path: event.target.value })} />
|
||||
<TextField label="图标" value={form.icon} onChange={(event) => setForm({ ...form, icon: event.target.value })} />
|
||||
<TextField label="权限码" value={form.permissionCode} onChange={(event) => setForm({ ...form, permissionCode: event.target.value })} />
|
||||
<TextField label="排序" type="number" value={form.sort} onChange={(event) => setForm({ ...form, sort: event.target.value })} />
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.visible} onChange={(event) => setForm({ ...form, visible: event.target.checked })} />}
|
||||
label={form.visible ? "显示" : "隐藏"}
|
||||
/>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">基础信息</div>
|
||||
<div className="form-drawer__grid">
|
||||
<TextField label="菜单名称" required value={form.label} onChange={(event) => setForm({ ...form, label: event.target.value })} />
|
||||
<TextField label="菜单编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
|
||||
<TextField label="父级 ID" type="number" value={form.parentId} onChange={(event) => setForm({ ...form, parentId: event.target.value })} />
|
||||
<TextField label="排序" type="number" value={form.sort} onChange={(event) => setForm({ ...form, sort: event.target.value })} />
|
||||
<AdminSwitch
|
||||
checked={form.visible}
|
||||
checkedLabel="显示"
|
||||
label="菜单显示状态"
|
||||
uncheckedLabel="隐藏"
|
||||
onChange={(event) => setForm({ ...form, visible: event.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">路由权限</div>
|
||||
<div className="form-drawer__grid">
|
||||
<TextField label="路由" value={form.path} onChange={(event) => setForm({ ...form, path: event.target.value })} />
|
||||
<TextField label="图标" value={form.icon} onChange={(event) => setForm({ ...form, icon: event.target.value })} />
|
||||
<TextField label="权限码" value={form.permissionCode} onChange={(event) => setForm({ ...form, permissionCode: event.target.value })} />
|
||||
</div>
|
||||
</section>
|
||||
<div className="form-drawer__actions">
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button type="submit" variant="primary">提交</Button>
|
||||
|
||||
@ -9,14 +9,22 @@ export function PermissionFormDrawer({ editingPermission, form, onClose, onSubmi
|
||||
<Drawer anchor="right" open={open} onClose={onClose}>
|
||||
<form className="form-drawer" onSubmit={onSubmit}>
|
||||
<h2>{editingPermission ? "编辑权限" : "新增权限"}</h2>
|
||||
<TextField label="权限名称" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
||||
<TextField label="权限编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
|
||||
<TextField label="类型" required select value={form.kind} onChange={(event) => setForm({ ...form, kind: event.target.value })}>
|
||||
{permissionKinds.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>{label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField label="说明" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">基础信息</div>
|
||||
<div className="form-drawer__grid">
|
||||
<TextField label="权限名称" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
||||
<TextField label="权限编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
|
||||
<TextField label="类型" required select value={form.kind} onChange={(event) => setForm({ ...form, kind: event.target.value })}>
|
||||
{permissionKinds.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>{label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</div>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">说明</div>
|
||||
<TextField label="说明" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
|
||||
</section>
|
||||
<div className="form-drawer__actions">
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button type="submit" variant="primary">提交</Button>
|
||||
|
||||
@ -3,7 +3,6 @@ import SyncOutlined from "@mui/icons-material/SyncOutlined";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
@ -21,9 +20,6 @@ export function MenuManagementPage() {
|
||||
<>
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<AdminFilterResetButton disabled={!page.query} onClick={page.resetFilters} />
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{page.abilities.canSyncPermission ? (
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
import { apiRequest } from "@/shared/api/request";
|
||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import type { EntityId, NotificationDto, NotificationListDto } from "@/shared/api/types";
|
||||
|
||||
export function listNotifications(): Promise<NotificationListDto> {
|
||||
return apiRequest<NotificationListDto>(apiEndpointPath(API_OPERATIONS.listNotifications));
|
||||
}
|
||||
|
||||
export function markNotificationRead(id: EntityId): Promise<NotificationDto> {
|
||||
const endpoint = API_ENDPOINTS.markNotificationRead;
|
||||
return apiRequest<NotificationDto, Record<string, never>>(apiEndpointPath(API_OPERATIONS.markNotificationRead, { id }), {
|
||||
body: {},
|
||||
method: endpoint.method
|
||||
});
|
||||
}
|
||||
|
||||
export function markAllNotificationsRead(): Promise<unknown> {
|
||||
const endpoint = API_ENDPOINTS.markAllNotificationsRead;
|
||||
return apiRequest<unknown, Record<string, never>>(apiEndpointPath(API_OPERATIONS.markAllNotificationsRead), {
|
||||
body: {},
|
||||
method: endpoint.method
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteNotification(id: EntityId): Promise<unknown> {
|
||||
const endpoint = API_ENDPOINTS.deleteNotification;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.deleteNotification, { id }), { method: endpoint.method });
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import DoneAllOutlined from "@mui/icons-material/DoneAllOutlined";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { Card } from "@/shared/ui/Card.jsx";
|
||||
|
||||
export function NotificationList({ abilities, items, onDelete, onRead }) {
|
||||
return (
|
||||
<Card className="table-card" component="section">
|
||||
<div className="notification-list">
|
||||
{(items || []).map((item) => (
|
||||
<div className={`notification-item notification-item--${item.level}`} key={item.id}>
|
||||
<div>
|
||||
<div className="notification-title">{item.title}</div>
|
||||
<div className="notification-content">{item.content}</div>
|
||||
<div className="muted">{formatDate(item.createdAt)}</div>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
{!item.readAt && abilities.canRead ? (
|
||||
<Button onClick={() => onRead(item.id)}>
|
||||
<DoneAllOutlined fontSize="small" />
|
||||
已读
|
||||
</Button>
|
||||
) : (
|
||||
<span className="muted">{item.readAt ? "已读" : "未读"}</span>
|
||||
)}
|
||||
{abilities.canDelete ? (
|
||||
<Button variant="danger" onClick={() => onDelete(item)}>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!items?.length ? <div className="empty-state">暂无通知</div> : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import DoneAllOutlined from "@mui/icons-material/DoneAllOutlined";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { PageHead } from "@/shared/ui/PageHead.jsx";
|
||||
|
||||
export function NotificationToolbar({ canReadAll, onReadAll, unread }) {
|
||||
return (
|
||||
<PageHead title="通知中心" meta={`未读 ${unread || 0} 条`}>
|
||||
{canReadAll ? (
|
||||
<Button onClick={onReadAll}>
|
||||
<DoneAllOutlined fontSize="small" />
|
||||
全部已读
|
||||
</Button>
|
||||
) : null}
|
||||
</PageHead>
|
||||
);
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
deleteNotification,
|
||||
listNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead
|
||||
} from "@/features/notifications/api";
|
||||
import { useNotificationAbilities } from "@/features/notifications/permissions.js";
|
||||
|
||||
const emptyData = { items: [], unread: 0 };
|
||||
|
||||
export function useNotificationsPage() {
|
||||
const abilities = useNotificationAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const queryFn = useCallback(() => {
|
||||
return listNotifications();
|
||||
}, []);
|
||||
const { data, error, loading, reload } = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载通知失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["notifications"]
|
||||
});
|
||||
|
||||
const read = async (id) => {
|
||||
await markNotificationRead(id);
|
||||
showToast("通知已标记为已读", "success");
|
||||
reload();
|
||||
};
|
||||
|
||||
const readAll = async () => {
|
||||
await markAllNotificationsRead();
|
||||
showToast("通知已全部标记为已读", "success");
|
||||
reload();
|
||||
};
|
||||
|
||||
const remove = async (item) => {
|
||||
const ok = await confirm({ confirmText: "删除", message: item.title, title: "删除通知", tone: "danger" });
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await deleteNotification(item.id);
|
||||
showToast("通知已删除", "success");
|
||||
reload();
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
data: data || emptyData,
|
||||
error,
|
||||
loading,
|
||||
read,
|
||||
readAll,
|
||||
reload,
|
||||
remove
|
||||
};
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
.notification-list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 86px;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--row-border);
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
margin: var(--space-2) 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { NotificationList } from "@/features/notifications/components/NotificationList.jsx";
|
||||
import { NotificationToolbar } from "@/features/notifications/components/NotificationToolbar.jsx";
|
||||
import { useNotificationsPage } from "@/features/notifications/hooks/useNotificationsPage.js";
|
||||
|
||||
export function NotificationsPage() {
|
||||
const page = useNotificationsPage();
|
||||
|
||||
return (
|
||||
<>
|
||||
<NotificationToolbar canReadAll={page.abilities.canReadAll} onReadAll={page.readAll} unread={page.data.unread} />
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<NotificationList abilities={page.abilities} items={page.data.items || []} onDelete={page.remove} onRead={page.read} />
|
||||
</DataState>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export function useNotificationAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canDelete: can(PERMISSIONS.notificationDelete),
|
||||
canRead: can(PERMISSIONS.notificationRead),
|
||||
canReadAll: can(PERMISSIONS.notificationReadAll)
|
||||
};
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const notificationsRoutes = [
|
||||
{
|
||||
label: "通知中心",
|
||||
loader: () => import("./pages/NotificationsPage.jsx").then((module) => module.NotificationsPage),
|
||||
menuCode: MENU_CODES.notifications,
|
||||
pageKey: "notifications",
|
||||
path: "/notifications",
|
||||
permission: PERMISSIONS.notificationView
|
||||
}
|
||||
];
|
||||
11
src/features/operations/api.ts
Normal file
11
src/features/operations/api.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { apiRequest } from "@/shared/api/request";
|
||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import type { ApiPage, CoinLedgerEntryDto, PageQuery } from "@/shared/api/types";
|
||||
|
||||
export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedgerEntryDto>> {
|
||||
const endpoint = API_ENDPOINTS.listCoinLedger;
|
||||
return apiRequest<ApiPage<CoinLedgerEntryDto>>(apiEndpointPath(API_OPERATIONS.listCoinLedger), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
});
|
||||
}
|
||||
170
src/features/operations/operations.module.css
Normal file
170
src/features/operations/operations.module.css
Normal file
@ -0,0 +1,170 @@
|
||||
.amount {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--success);
|
||||
font-size: calc(var(--admin-font-size) + 2px);
|
||||
font-weight: 760;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.amountIncome {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.amountExpense {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.clickableRow:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.clickableRow:focus-visible {
|
||||
outline: none;
|
||||
background: var(--primary-hover);
|
||||
box-shadow: inset 0 0 0 2px var(--primary-border-strong);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.avatar,
|
||||
.detailAvatar {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card-strong);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.avatar img,
|
||||
.detailAvatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.identityText,
|
||||
.detailHeroText {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.primaryText {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta,
|
||||
.detailMeta {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detailHero {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.detailAvatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.detailTitleRow {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.detailTitleRow h3 {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detailSection {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.detailSectionTitle {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.detailGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.detailItem {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detailLabel {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detailValue {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.detailCode {
|
||||
max-height: 180px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.detailGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
509
src/features/operations/pages/CoinLedgerPage.jsx
Normal file
509
src/features/operations/pages/CoinLedgerPage.jsx
Normal file
@ -0,0 +1,509 @@
|
||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
||||
import { useMemo, useState } from "react";
|
||||
import { listCoinLedger } from "@/features/operations/api";
|
||||
import {
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import styles from "@/features/operations/operations.module.css";
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "user",
|
||||
label: "用户",
|
||||
width: "minmax(260px, 1.35fr)",
|
||||
render: (entry) => <UserCell entry={entry} />,
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
label: "数量",
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
render: (entry) => <AmountValue entry={entry} />,
|
||||
},
|
||||
{
|
||||
key: "bizType",
|
||||
label: "类型",
|
||||
width: "minmax(150px, 0.8fr)",
|
||||
render: (entry) => bizTypeLabel(entry.bizType),
|
||||
},
|
||||
{
|
||||
key: "balance",
|
||||
label: "余额",
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
render: (entry) => formatNumber(entry.availableAfter),
|
||||
},
|
||||
{
|
||||
key: "createdAtMs",
|
||||
label: "时间",
|
||||
width: "minmax(170px, 0.85fr)",
|
||||
render: (entry) => formatMillis(entry.createdAtMs),
|
||||
},
|
||||
];
|
||||
|
||||
const hiddenMetadataKeys = new Set(["presentation_json", "resource_snapshot_json"]);
|
||||
|
||||
const metadataLabels = {
|
||||
amount: "数量",
|
||||
app_code: "App",
|
||||
applied_at_ms: "入账时间",
|
||||
asset_type: "资产类型",
|
||||
available_delta: "可用变化",
|
||||
balance_after: "余额",
|
||||
billing_receipt_id: "账单 ID",
|
||||
charge_amount: "扣费数量",
|
||||
charge_asset_type: "扣费资产",
|
||||
coin_amount: "金币数量",
|
||||
coin_price: "单价",
|
||||
coin_spent: "消耗金币",
|
||||
counts_as_seller_recharge: "计入币商充值",
|
||||
cycle_key: "周期",
|
||||
evidence_ref: "凭证",
|
||||
game_id: "游戏 ID",
|
||||
gift_count: "礼物数量",
|
||||
gift_id: "礼物 ID",
|
||||
gift_name: "礼物名称",
|
||||
gift_point_added: "礼物积分",
|
||||
heat_value: "热度",
|
||||
operator_user_id: "操作人",
|
||||
op_type: "操作类型",
|
||||
paid_amount_micro: "支付金额",
|
||||
paid_currency_code: "支付币种",
|
||||
payment_ref: "支付凭证",
|
||||
platform_code: "游戏平台",
|
||||
price_version: "价格版本",
|
||||
provider_order_id: "游戏订单",
|
||||
provider_round_id: "游戏局号",
|
||||
reason: "原因",
|
||||
recharge_currency_code: "充值币种",
|
||||
recharge_policy_coin_amount: "策略金币",
|
||||
recharge_policy_id: "充值策略 ID",
|
||||
recharge_policy_usd_minor_amount: "策略 USD 金额",
|
||||
recharge_policy_version: "充值策略版本",
|
||||
recharge_usd_minor: "充值 USD 金额",
|
||||
room_id: "房间 ID",
|
||||
seller_asset_type: "币商资产",
|
||||
seller_balance_after: "币商余额",
|
||||
seller_region_id: "币商区域",
|
||||
seller_user_id: "币商用户",
|
||||
sender_user_id: "赠送人",
|
||||
sort_order: "排序",
|
||||
stock_type: "进货类型",
|
||||
target_asset_type: "目标资产",
|
||||
target_balance_after: "接收方余额",
|
||||
target_region_id: "接收方区域",
|
||||
target_user_id: "接收用户",
|
||||
task_id: "任务 ID",
|
||||
task_type: "任务类型",
|
||||
user_id: "用户 ID",
|
||||
};
|
||||
|
||||
export function CoinLedgerPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedEntry, setSelectedEntry] = useState(null);
|
||||
const [userKeyword, setUserKeyword] = useState("");
|
||||
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
end_at_ms: timeRange.endMs || "",
|
||||
start_at_ms: timeRange.startMs || "",
|
||||
user_keyword: userKeyword,
|
||||
}),
|
||||
[timeRange.endMs, timeRange.startMs, userKeyword],
|
||||
);
|
||||
const query = usePaginatedQuery({
|
||||
errorMessage: "获取金币流水失败",
|
||||
fetcher: listCoinLedger,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["coin-ledger", filters, page],
|
||||
});
|
||||
const data = query.data || { items: [], total: 0, page, pageSize };
|
||||
const items = data.items || [];
|
||||
const total = data.total || 0;
|
||||
|
||||
const changeFilter = (setter) => (value) => {
|
||||
setter(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeTimeRange = (nextRange) => {
|
||||
setTimeRange(nextRange);
|
||||
setPage(1);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setUserKeyword("");
|
||||
setTimeRange({ endMs: "", startMs: "" });
|
||||
setPage(1);
|
||||
};
|
||||
const closeDetail = () => {
|
||||
setSelectedEntry(null);
|
||||
};
|
||||
|
||||
const tableColumns = columns.map((column) =>
|
||||
column.key === "user"
|
||||
? {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "长 ID / 短 ID / 昵称",
|
||||
value: userKeyword,
|
||||
onChange: changeFilter(setUserKeyword),
|
||||
}),
|
||||
}
|
||||
: column,
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<>
|
||||
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
||||
<AdminFilterResetButton
|
||||
disabled={!userKeyword && !timeRange.startMs && !timeRange.endMs}
|
||||
onClick={resetFilters}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
getRowProps={(entry) => coinLedgerRowProps(entry, setSelectedEntry)}
|
||||
items={items}
|
||||
minWidth="900px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page,
|
||||
pageSize: data.pageSize || pageSize,
|
||||
total,
|
||||
onPageChange: setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(entry) => entry.entryId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<CoinLedgerDetailDrawer entry={selectedEntry} open={Boolean(selectedEntry)} onClose={closeDetail} />
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function coinLedgerRowProps(entry, onOpen) {
|
||||
const user = entry.user || {};
|
||||
const userLabel = user.username || user.displayUserId || user.userId || entry.userId || "用户";
|
||||
return {
|
||||
"aria-label": `查看 ${userLabel} 的金币流水详情`,
|
||||
className: styles.clickableRow,
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
onClick: () => onOpen(entry),
|
||||
onKeyDown: (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpen(entry);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function CoinLedgerDetailDrawer({ entry, onClose, open }) {
|
||||
const metadata = metadataObject(entry?.metadata);
|
||||
const contextRows = entry ? businessContextRows(entry, metadata) : [];
|
||||
const usedMetadataKeys = new Set(contextRows.flatMap((row) => row.metadataKeys || []));
|
||||
const metadataRows = metadataDetailRows(metadata, usedMetadataKeys);
|
||||
const user = entry?.user || {};
|
||||
|
||||
return (
|
||||
<SideDrawer open={open} title="流水详情" width="wide" onClose={onClose}>
|
||||
{entry ? (
|
||||
<>
|
||||
<header className={styles.detailHero}>
|
||||
<span className={styles.detailAvatar}>
|
||||
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="medium" />}
|
||||
</span>
|
||||
<div className={styles.detailHeroText}>
|
||||
<div className={styles.detailTitleRow}>
|
||||
<h3>{user.username || "-"}</h3>
|
||||
<AmountValue entry={entry} />
|
||||
</div>
|
||||
<span className={styles.detailMeta}>{userIdText(entry)}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<DetailSection
|
||||
items={[
|
||||
detailRow("交易 ID", entry.transactionId),
|
||||
detailRow("命令 ID", entry.commandId),
|
||||
detailRow("外部单号", entry.externalRef),
|
||||
detailRow("流水 ID", entry.entryId),
|
||||
detailRow("业务类型", bizTypeLabel(entry.bizType)),
|
||||
detailRow("方向", directionLabel(entry.direction)),
|
||||
detailRow("发生时间", formatMillis(entry.createdAtMs)),
|
||||
]}
|
||||
title="交易信息"
|
||||
/>
|
||||
|
||||
<DetailSection
|
||||
items={[
|
||||
detailRow("流水数量", formatSignedAmount(entry)),
|
||||
detailRow("可用变化", formatSignedDelta(entry.availableDelta)),
|
||||
detailRow("可用余额", formatNumber(entry.availableAfter)),
|
||||
]}
|
||||
title="金额变化"
|
||||
/>
|
||||
|
||||
<DetailSection items={contextRows} title="业务上下文" />
|
||||
<DetailSection items={metadataRows} title="扩展明细" />
|
||||
</>
|
||||
) : null}
|
||||
</SideDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function UserCell({ entry }) {
|
||||
const user = entry.user || {};
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.avatar}>
|
||||
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
|
||||
</span>
|
||||
<div className={styles.identityText}>
|
||||
<span className={styles.primaryText}>{user.username || "-"}</span>
|
||||
<span className={styles.meta}>{userIdText(entry)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AmountValue({ entry }) {
|
||||
const expense = entry.direction === "expense";
|
||||
return (
|
||||
<span className={`${styles.amount} ${expense ? styles.amountExpense : styles.amountIncome}`}>
|
||||
{formatSignedAmount(entry)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({ items, title }) {
|
||||
const visibleItems = items.filter((item) => hasDetailValue(item.value));
|
||||
if (!visibleItems.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<section className={styles.detailSection}>
|
||||
<h3 className={styles.detailSectionTitle}>{title}</h3>
|
||||
<div className={styles.detailGrid}>
|
||||
{visibleItems.map((item) => (
|
||||
<DetailItem key={item.label} label={item.label} value={item.value} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailItem({ label, value }) {
|
||||
return (
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>{label}</span>
|
||||
<div className={styles.detailValue}>{displayDetailValue(value)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function businessContextRows(entry, metadata) {
|
||||
if (entry.bizType === "gift_debit") {
|
||||
return compactRows([
|
||||
metadataRow("礼物名称", metadata, ["gift_name", "giftName"]),
|
||||
metadataRow("礼物 ID", metadata, ["gift_id", "giftId"]),
|
||||
metadataRow("礼物数量", metadata, ["gift_count", "giftCount"]),
|
||||
metadataRow("赠送给", metadata, ["target_user_id", "targetUserId"], entry.counterpartyUserId),
|
||||
metadataRow("赠送人", metadata, ["sender_user_id", "senderUserId"], entry.userId),
|
||||
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
|
||||
metadataRow("消耗金币", metadata, ["coin_spent", "coinSpent", "charge_amount", "chargeAmount"]),
|
||||
metadataRow("礼物积分", metadata, ["gift_point_added", "giftPointAdded"]),
|
||||
metadataRow("热度", metadata, ["heat_value", "heatValue"]),
|
||||
metadataRow("账单 ID", metadata, ["billing_receipt_id", "billingReceiptId"]),
|
||||
metadataRow("价格版本", metadata, ["price_version", "priceVersion"]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (String(entry.bizType || "").startsWith("game_")) {
|
||||
return compactRows([
|
||||
metadataRow("游戏 ID", metadata, ["game_id", "gameId"]),
|
||||
metadataRow("游戏平台", metadata, ["platform_code", "platformCode"]),
|
||||
metadataRow("游戏订单", metadata, ["provider_order_id", "providerOrderId"], entry.externalRef),
|
||||
metadataRow("游戏局号", metadata, ["provider_round_id", "providerRoundId"]),
|
||||
metadataRow("游戏操作", metadata, ["op_type", "opType"]),
|
||||
metadataRow("游戏房间", metadata, ["room_id", "roomId"], entry.roomId),
|
||||
metadataRow("游戏金币", metadata, ["coin_amount", "coinAmount"]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (entry.bizType === "coin_seller_transfer") {
|
||||
return compactRows([
|
||||
metadataRow("币商用户", metadata, ["seller_user_id", "sellerUserId"]),
|
||||
metadataRow("接收用户", metadata, ["target_user_id", "targetUserId"], entry.counterpartyUserId),
|
||||
metadataRow("转账金额", metadata, ["amount"]),
|
||||
metadataRow("原因", metadata, ["reason"]),
|
||||
metadataRow("币商余额", metadata, ["seller_balance_after", "sellerBalanceAfter"]),
|
||||
metadataRow("接收方余额", metadata, ["target_balance_after", "targetBalanceAfter"]),
|
||||
metadataRow("充值策略 ID", metadata, ["recharge_policy_id", "rechargePolicyId"]),
|
||||
metadataRow("充值币种", metadata, ["recharge_currency_code", "rechargeCurrencyCode"]),
|
||||
]);
|
||||
}
|
||||
|
||||
return compactRows([
|
||||
metadataRow("关联用户", metadata, ["target_user_id", "targetUserId", "seller_user_id", "sellerUserId"], entry.counterpartyUserId),
|
||||
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
|
||||
metadataRow("任务 ID", metadata, ["task_id", "taskId"]),
|
||||
metadataRow("任务类型", metadata, ["task_type", "taskType"]),
|
||||
metadataRow("周期", metadata, ["cycle_key", "cycleKey"]),
|
||||
metadataRow("原因", metadata, ["reason"]),
|
||||
metadataRow("操作人", metadata, ["operator_user_id", "operatorUserId"]),
|
||||
metadataRow("凭证", metadata, ["evidence_ref", "evidenceRef"]),
|
||||
]);
|
||||
}
|
||||
|
||||
function metadataDetailRows(metadata, usedMetadataKeys) {
|
||||
return Object.entries(metadata)
|
||||
.filter(([key, value]) => !usedMetadataKeys.has(key) && !hiddenMetadataKeys.has(key) && hasDetailValue(value))
|
||||
.map(([key, value]) => detailRow(metadataLabel(key), normalizeMetadataValue(value), [key]));
|
||||
}
|
||||
|
||||
function metadataRow(label, metadata, keys, fallback) {
|
||||
return detailRow(label, firstValue(valueFromMetadata(metadata, keys), fallback), keys);
|
||||
}
|
||||
|
||||
function detailRow(label, value, metadataKeys = []) {
|
||||
return { label, metadataKeys, value };
|
||||
}
|
||||
|
||||
function compactRows(rows) {
|
||||
return rows.filter((row) => hasDetailValue(row.value));
|
||||
}
|
||||
|
||||
function valueFromMetadata(metadata, keys) {
|
||||
for (const key of keys) {
|
||||
if (hasDetailValue(metadata[key])) {
|
||||
return metadata[key];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstValue(...values) {
|
||||
return values.find((value) => hasDetailValue(value));
|
||||
}
|
||||
|
||||
function metadataObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeMetadataValue(value) {
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function displayDetailValue(value) {
|
||||
if (!hasDetailValue(value)) {
|
||||
return "-";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return <pre className={styles.detailCode}>{JSON.stringify(value, null, 2)}</pre>;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function userIdText(entry) {
|
||||
const user = entry.user || {};
|
||||
const longId = user.userId || entry.userId || "";
|
||||
const shortId = user.displayUserId || "";
|
||||
if (longId && shortId && longId !== shortId) {
|
||||
return `${longId}(${shortId})`;
|
||||
}
|
||||
return longId || shortId || "-";
|
||||
}
|
||||
|
||||
function hasDetailValue(value) {
|
||||
return value === 0 || value === false || Boolean(value);
|
||||
}
|
||||
|
||||
function formatSignedAmount(entry) {
|
||||
const sign = entry.direction === "expense" ? "-" : "+";
|
||||
const amount = hasDetailValue(entry.amount) ? entry.amount : Math.abs(Number(entry.availableDelta || 0));
|
||||
return `${sign}${formatNumber(amount)}`;
|
||||
}
|
||||
|
||||
function formatSignedDelta(value) {
|
||||
if (!hasDetailValue(value)) {
|
||||
return "-";
|
||||
}
|
||||
const numberValue = Number(value);
|
||||
if (Number.isNaN(numberValue)) {
|
||||
return String(value);
|
||||
}
|
||||
return `${numberValue > 0 ? "+" : ""}${formatNumber(numberValue)}`;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === 0 || value) {
|
||||
return Number(value).toLocaleString("zh-CN");
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
function directionLabel(value) {
|
||||
const labels = {
|
||||
expense: "支出",
|
||||
income: "收入",
|
||||
};
|
||||
return labels[value] || value || "-";
|
||||
}
|
||||
|
||||
function metadataLabel(key) {
|
||||
return metadataLabels[key] || key;
|
||||
}
|
||||
|
||||
function bizTypeLabel(value) {
|
||||
const labels = {
|
||||
coin_seller_transfer: "币商转账",
|
||||
game_credit: "游戏入账",
|
||||
game_debit: "游戏扣费",
|
||||
game_refund: "游戏退款",
|
||||
game_reverse: "游戏冲正",
|
||||
gift_debit: "礼物扣费",
|
||||
manual_credit: "人工入账",
|
||||
resource_grant: "资源发放",
|
||||
task_reward: "任务奖励",
|
||||
vip_purchase: "VIP 购买",
|
||||
};
|
||||
return labels[value] || value || "-";
|
||||
}
|
||||
12
src/features/operations/routes.js
Normal file
12
src/features/operations/routes.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const operationsRoutes = [
|
||||
{
|
||||
label: "金币流水",
|
||||
loader: () => import("./pages/CoinLedgerPage.jsx").then((module) => module.CoinLedgerPage),
|
||||
menuCode: MENU_CODES.operationCoinLedger,
|
||||
pageKey: "operation-coin-ledger",
|
||||
path: "/operations/coin-ledger",
|
||||
permission: PERMISSIONS.coinLedgerView,
|
||||
},
|
||||
];
|
||||
@ -1,11 +1,58 @@
|
||||
import { apiRequest } from "@/shared/api/request";
|
||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import type { ApiPage, PageQuery, RechargeBillDto } from "@/shared/api/types";
|
||||
import type {
|
||||
ApiPage,
|
||||
EntityId,
|
||||
PageQuery,
|
||||
RechargeBillDto,
|
||||
RechargeProductDto,
|
||||
RechargeProductPayload,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
export function listRechargeBills(query: PageQuery = {}): Promise<ApiPage<RechargeBillDto>> {
|
||||
const endpoint = API_ENDPOINTS.listRechargeBills;
|
||||
return apiRequest<ApiPage<RechargeBillDto>>(apiEndpointPath(API_OPERATIONS.listRechargeBills), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
});
|
||||
const endpoint = API_ENDPOINTS.listRechargeBills;
|
||||
return apiRequest<ApiPage<RechargeBillDto>>(apiEndpointPath(API_OPERATIONS.listRechargeBills), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
export function listRechargeProducts(query: PageQuery = {}): Promise<ApiPage<RechargeProductDto>> {
|
||||
const endpoint = API_ENDPOINTS.listRechargeProducts;
|
||||
return apiRequest<ApiPage<RechargeProductDto>>(apiEndpointPath(API_OPERATIONS.listRechargeProducts), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
export function createRechargeProduct(payload: RechargeProductPayload): Promise<RechargeProductDto> {
|
||||
const endpoint = API_ENDPOINTS.createRechargeProduct;
|
||||
return apiRequest<RechargeProductDto, RechargeProductPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.createRechargeProduct),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function updateRechargeProduct(
|
||||
productId: EntityId,
|
||||
payload: RechargeProductPayload,
|
||||
): Promise<RechargeProductDto> {
|
||||
const endpoint = API_ENDPOINTS.updateRechargeProduct;
|
||||
return apiRequest<RechargeProductDto, RechargeProductPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateRechargeProduct, { product_id: productId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteRechargeProduct(productId: EntityId): Promise<unknown> {
|
||||
const endpoint = API_ENDPOINTS.deleteRechargeProduct;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.deleteRechargeProduct, { product_id: productId }), {
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
197
src/features/payment/hooks/useRechargeProductsPage.js
Normal file
197
src/features/payment/hooks/useRechargeProductsPage.js
Normal file
@ -0,0 +1,197 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
createRechargeProduct,
|
||||
deleteRechargeProduct,
|
||||
listRechargeProducts,
|
||||
updateRechargeProduct,
|
||||
} from "@/features/payment/api";
|
||||
import { usePaymentAbilities } from "@/features/payment/permissions.js";
|
||||
import { rechargeProductSchema } from "@/features/payment/schema";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
const emptyForm = () => ({
|
||||
amountUsdt: "",
|
||||
coinAmount: "",
|
||||
description: "",
|
||||
enabled: true,
|
||||
platform: "android",
|
||||
productName: "",
|
||||
regionIds: [],
|
||||
});
|
||||
|
||||
export function useRechargeProductsPage() {
|
||||
const abilities = usePaymentAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [platform, setPlatform] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [form, setFormState] = useState(emptyForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword,
|
||||
platform,
|
||||
regionId,
|
||||
status,
|
||||
}),
|
||||
[keyword, platform, regionId, status],
|
||||
);
|
||||
const result = usePaginatedQuery({
|
||||
errorMessage: "加载内购配置失败",
|
||||
fetcher: listRechargeProducts,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["payment", "recharge-products", filters, page],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [filters]);
|
||||
|
||||
const setForm = (patch) => {
|
||||
setFormState((current) => ({ ...current, ...patch }));
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingItem(null);
|
||||
setFormState(emptyForm());
|
||||
setActiveAction("create");
|
||||
};
|
||||
|
||||
const openEdit = (item) => {
|
||||
setEditingItem(item);
|
||||
setFormState(formFromProduct(item));
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setActiveAction("");
|
||||
setEditingItem(null);
|
||||
setFormState(emptyForm());
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setKeyword("");
|
||||
setStatus("");
|
||||
setPlatform("");
|
||||
setRegionId("");
|
||||
};
|
||||
|
||||
const submitProduct = async (event) => {
|
||||
event.preventDefault();
|
||||
let payload;
|
||||
try {
|
||||
payload = parseForm(rechargeProductSchema, normalizeForm(form));
|
||||
} catch (err) {
|
||||
showToast(err.message || "表单校验失败", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const action = editingItem ? "edit" : "create";
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
if (editingItem) {
|
||||
await updateRechargeProduct(editingItem.productId, payload);
|
||||
showToast("内购配置已更新", "success");
|
||||
} else {
|
||||
await createRechargeProduct(payload);
|
||||
showToast("内购配置已新增", "success");
|
||||
}
|
||||
closeDialog();
|
||||
await result.reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeProduct = async (item) => {
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: item.productName || item.productCode || `内购配置 ${item.productId}`,
|
||||
title: "删除内购配置",
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
setLoadingAction(`delete:${item.productId}`);
|
||||
try {
|
||||
await deleteRechargeProduct(item.productId);
|
||||
await result.reload();
|
||||
showToast("内购配置已删除", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "删除失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
closeDialog,
|
||||
data: result.data,
|
||||
editingItem,
|
||||
error: result.error,
|
||||
form,
|
||||
keyword,
|
||||
loading: result.loading,
|
||||
loadingAction,
|
||||
loadingRegions,
|
||||
openCreate,
|
||||
openEdit,
|
||||
page,
|
||||
pageSize,
|
||||
platform,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload: result.reload,
|
||||
removeProduct,
|
||||
resetFilters,
|
||||
setForm,
|
||||
setKeyword,
|
||||
setPage,
|
||||
setPlatform,
|
||||
setRegionId,
|
||||
setStatus,
|
||||
status,
|
||||
submitProduct,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeForm(form) {
|
||||
return {
|
||||
...form,
|
||||
amountUsdt: form.amountUsdt || "",
|
||||
coinAmount: form.coinAmount || "",
|
||||
regionIds: form.regionIds || [],
|
||||
};
|
||||
}
|
||||
|
||||
function formFromProduct(item) {
|
||||
return {
|
||||
amountUsdt: item.amountUsdt || "",
|
||||
coinAmount: item.coinAmount === 0 || item.coinAmount ? String(item.coinAmount) : "",
|
||||
description: item.description || "",
|
||||
enabled: item.enabled ?? item.status === "active",
|
||||
platform: item.platform || "android",
|
||||
productName: item.productName || "",
|
||||
regionIds: (item.regionIds || []).map(String),
|
||||
};
|
||||
}
|
||||
@ -1,14 +1,11 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { listRechargeBills } from "@/features/payment/api";
|
||||
import {
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
@ -130,14 +127,6 @@ export function PaymentBillListPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setKeyword("");
|
||||
setStatus("");
|
||||
setRechargeType("");
|
||||
setUserId("");
|
||||
setSellerUserId("");
|
||||
setPage(1);
|
||||
};
|
||||
const tableColumns = columns.map((column) => {
|
||||
if (column.key === "bill") {
|
||||
return {
|
||||
@ -196,30 +185,24 @@ export function PaymentBillListPage() {
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<AdminFilterResetButton
|
||||
disabled={!keyword && !status && !rechargeType && !userId && !sellerUserId}
|
||||
onClick={resetFilters}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
items={items}
|
||||
minWidth="1260px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page,
|
||||
pageSize: data.pageSize || pageSize,
|
||||
total,
|
||||
onPageChange: setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(bill) => bill.transactionId}
|
||||
/>
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page}
|
||||
pageSize={data.pageSize || pageSize}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</AdminListPage>
|
||||
|
||||
340
src/features/payment/pages/RechargeProductConfigPage.jsx
Normal file
340
src/features/payment/pages/RechargeProductConfigPage.jsx
Normal file
@ -0,0 +1,340 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo } from "react";
|
||||
import { useRechargeProductsPage } from "@/features/payment/hooks/useRechargeProductsPage.js";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import {
|
||||
AdminFormAmountField,
|
||||
AdminFormDialog,
|
||||
AdminFormFieldGrid,
|
||||
AdminFormSection,
|
||||
AdminFormSwitchField,
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
|
||||
const statusOptions = [
|
||||
["", "全部状态"],
|
||||
["active", "上架"],
|
||||
["disabled", "下架"],
|
||||
];
|
||||
|
||||
const platformOptions = [
|
||||
["", "全部平台"],
|
||||
["android", "Android"],
|
||||
["ios", "iOS"],
|
||||
];
|
||||
|
||||
export function RechargeProductConfigPage() {
|
||||
const page = useRechargeProductsPage();
|
||||
const items = page.data?.items || [];
|
||||
const regionLabels = useMemo(() => regionLabelMap(page.regionOptions), [page.regionOptions]);
|
||||
const regionValues = useMemo(() => page.regionOptions.map((option) => option.value), [page.regionOptions]);
|
||||
const columns = useMemo(() => productColumns(page, regionLabels), [page, regionLabels]);
|
||||
const saving = page.loadingAction === "create" || page.loadingAction === "edit";
|
||||
const canSubmit = page.editingItem ? page.abilities.canUpdateProduct : page.abilities.canCreateProduct;
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
page.abilities.canCreateProduct ? (
|
||||
<Button
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={page.openCreate}
|
||||
>
|
||||
新增内购
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
minWidth="1180px"
|
||||
pagination={{
|
||||
page: page.page,
|
||||
pageSize: page.pageSize,
|
||||
total: page.data?.total || 0,
|
||||
onPageChange: page.setPage,
|
||||
}}
|
||||
rowKey={(item) => item.productId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
|
||||
<AdminFormDialog
|
||||
loading={saving}
|
||||
open={Boolean(page.activeAction)}
|
||||
size="compact"
|
||||
submitDisabled={!canSubmit || saving}
|
||||
title={page.editingItem ? "编辑内购配置" : "新增内购配置"}
|
||||
onClose={page.closeDialog}
|
||||
onSubmit={page.submitProduct}
|
||||
>
|
||||
<AdminFormSection title="商品信息">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
label="产品名称"
|
||||
required
|
||||
value={page.form.productName}
|
||||
onChange={(event) => page.setForm({ productName: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
label="平台"
|
||||
required
|
||||
select
|
||||
value={page.form.platform}
|
||||
onChange={(event) => page.setForm({ platform: event.target.value })}
|
||||
>
|
||||
<MenuItem value="android">Android</MenuItem>
|
||||
<MenuItem value="ios">iOS</MenuItem>
|
||||
</TextField>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="价格配置">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<AdminFormAmountField
|
||||
disabled={!canSubmit}
|
||||
inputProps={{ inputMode: "decimal" }}
|
||||
label="金额USDT"
|
||||
required
|
||||
unit="USDT"
|
||||
value={page.form.amountUsdt}
|
||||
onChange={(event) => page.setForm({ amountUsdt: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
inputProps={{ min: 1, step: 1 }}
|
||||
label="获得金币数"
|
||||
required
|
||||
type="number"
|
||||
value={page.form.coinAmount}
|
||||
onChange={(event) => page.setForm({ coinAmount: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="投放设置">
|
||||
<MultiValueAutocomplete
|
||||
disabled={!canSubmit}
|
||||
label="支持区域"
|
||||
labels={regionLabels}
|
||||
loading={page.loadingRegions}
|
||||
options={regionValues}
|
||||
required
|
||||
value={page.form.regionIds}
|
||||
onChange={(regionIds) => page.setForm({ regionIds })}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={Boolean(page.form.enabled)}
|
||||
checkedLabel="上架"
|
||||
disabled={!canSubmit}
|
||||
label="是否上架"
|
||||
uncheckedLabel="下架"
|
||||
onChange={(checked) => page.setForm({ enabled: checked })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="描述">
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
label="描述"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={page.form.description}
|
||||
onChange={(event) => page.setForm({ description: event.target.value })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function productColumns(page, regionLabels) {
|
||||
const columns = [
|
||||
{
|
||||
key: "product",
|
||||
label: "产品",
|
||||
render: (item) => <Stack primary={item.productName} secondary={item.productCode || "-"} />,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索产品、描述",
|
||||
value: page.keyword,
|
||||
onChange: page.setKeyword,
|
||||
}),
|
||||
width: "minmax(240px, 1.2fr)",
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
label: "金额 / 金币",
|
||||
render: (item) => (
|
||||
<Stack
|
||||
primary={`${item.amountUsdt || formatMicroAmount(item.amountUsdtMicro)} USDT`}
|
||||
secondary={`${formatNumber(item.coinAmount)} 金币`}
|
||||
/>
|
||||
),
|
||||
width: "minmax(160px, 0.75fr)",
|
||||
},
|
||||
{
|
||||
key: "platform",
|
||||
label: "平台",
|
||||
render: (item) => platformLabel(item.platform),
|
||||
filter: createOptionsColumnFilter({
|
||||
options: platformOptions,
|
||||
placeholder: "搜索平台",
|
||||
value: page.platform,
|
||||
onChange: page.setPlatform,
|
||||
}),
|
||||
width: "minmax(120px, 0.55fr)",
|
||||
},
|
||||
{
|
||||
key: "regionIds",
|
||||
label: "支持区域",
|
||||
render: (item) => regionListLabel(regionLabels, item.regionIds),
|
||||
filter: createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.setRegionId,
|
||||
}),
|
||||
width: "minmax(220px, 1fr)",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (item) => <StatusBadge enabled={item.enabled} />,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: statusOptions,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.setStatus,
|
||||
}),
|
||||
width: "minmax(110px, 0.5fr)",
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "描述",
|
||||
render: (item) => item.description || "-",
|
||||
width: "minmax(180px, 0.9fr)",
|
||||
},
|
||||
{
|
||||
key: "updatedAtMs",
|
||||
label: "更新时间",
|
||||
render: (item) => formatMillis(item.updatedAtMs),
|
||||
width: "minmax(160px, 0.75fr)",
|
||||
},
|
||||
];
|
||||
|
||||
if (!page.abilities.canUpdateProduct && !page.abilities.canDeleteProduct) {
|
||||
return columns;
|
||||
}
|
||||
|
||||
return [
|
||||
...columns,
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <ProductActions item={item} page={page} />,
|
||||
width: "minmax(96px, 0.45fr)",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function ProductActions({ item, page }) {
|
||||
const deleting = page.loadingAction === `delete:${item.productId}`;
|
||||
return (
|
||||
<AdminRowActions>
|
||||
{page.abilities.canUpdateProduct ? (
|
||||
<AdminActionIconButton
|
||||
disabled={Boolean(page.loadingAction)}
|
||||
label="编辑"
|
||||
onClick={() => page.openEdit(item)}
|
||||
>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canDeleteProduct ? (
|
||||
<AdminActionIconButton
|
||||
disabled={deleting || Boolean(page.loadingAction)}
|
||||
label="删除"
|
||||
onClick={() => page.removeProduct(item)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
|
||||
function Stack({ primary, secondary }) {
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<span>{primary || "-"}</span>
|
||||
<span className="muted">{secondary || "-"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ enabled }) {
|
||||
const tone = enabled ? "succeeded" : "stopped";
|
||||
return (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{enabled ? "上架" : "下架"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function platformLabel(value) {
|
||||
if (value === "android") {
|
||||
return "Android";
|
||||
}
|
||||
if (value === "ios") {
|
||||
return "iOS";
|
||||
}
|
||||
return value || "-";
|
||||
}
|
||||
|
||||
function regionLabelMap(options) {
|
||||
return options.reduce((labels, option) => {
|
||||
labels[String(option.value)] = option.label;
|
||||
return labels;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function regionListLabel(labels, regionIds = []) {
|
||||
if (!regionIds.length) {
|
||||
return "-";
|
||||
}
|
||||
return regionIds.map((regionId) => labels[String(regionId)] || `区域 ${regionId}`).join("、");
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
const numberValue = Number(value || 0);
|
||||
return Number.isFinite(numberValue) ? numberValue.toLocaleString() : "-";
|
||||
}
|
||||
|
||||
function formatMicroAmount(value) {
|
||||
const numberValue = Number(value || 0);
|
||||
if (!Number.isFinite(numberValue) || numberValue <= 0) {
|
||||
return "0";
|
||||
}
|
||||
return String(numberValue / 1_000_000).replace(/\.?0+$/, "");
|
||||
}
|
||||
14
src/features/payment/permissions.js
Normal file
14
src/features/payment/permissions.js
Normal file
@ -0,0 +1,14 @@
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export function usePaymentAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canCreateProduct: can(PERMISSIONS.paymentProductCreate),
|
||||
canDeleteProduct: can(PERMISSIONS.paymentProductDelete),
|
||||
canUpdateProduct: can(PERMISSIONS.paymentProductUpdate),
|
||||
canViewBill: can(PERMISSIONS.paymentBillView),
|
||||
canViewProduct: can(PERMISSIONS.paymentProductView),
|
||||
};
|
||||
}
|
||||
@ -1,12 +1,12 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const paymentRoutes = [
|
||||
{
|
||||
label: "账单列表",
|
||||
loader: () => import("./pages/PaymentBillListPage.jsx").then((module) => module.PaymentBillListPage),
|
||||
menuCode: MENU_CODES.paymentBillList,
|
||||
pageKey: "payment-bill-list",
|
||||
path: "/payment/bills",
|
||||
permission: PERMISSIONS.paymentBillView
|
||||
}
|
||||
{
|
||||
label: "账单列表",
|
||||
loader: () => import("./pages/PaymentBillListPage.jsx").then((module) => module.PaymentBillListPage),
|
||||
menuCode: MENU_CODES.paymentBillList,
|
||||
pageKey: "payment-bill-list",
|
||||
path: "/payment/bills",
|
||||
permission: PERMISSIONS.paymentBillView,
|
||||
}
|
||||
];
|
||||
|
||||
19
src/features/payment/schema.ts
Normal file
19
src/features/payment/schema.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const usdtAmountSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^(0|[1-9]\d*)(\.\d{1,6})?$/, "金额USDT最多支持 6 位小数")
|
||||
.refine((value) => !/^0(?:\.0{1,6})?$/.test(value), "金额USDT必须大于 0");
|
||||
|
||||
export const rechargeProductSchema = z.object({
|
||||
amountUsdt: usdtAmountSchema,
|
||||
coinAmount: z.coerce.number().int("获得金币数必须是整数").positive("获得金币数必须大于 0"),
|
||||
description: z.string().trim().max(512, "描述不能超过 512 个字符").default(""),
|
||||
enabled: z.boolean(),
|
||||
platform: z.enum(["android", "ios"]),
|
||||
productName: z.string().trim().min(1, "请填写产品名称").max(128, "产品名称不能超过 128 个字符"),
|
||||
regionIds: z.array(z.coerce.number().int().positive()).min(1, "请选择支持区域"),
|
||||
});
|
||||
|
||||
export type RechargeProductForm = z.infer<typeof rechargeProductSchema>;
|
||||
133
src/features/registration-reward/api.ts
Normal file
133
src/features/registration-reward/api.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { apiRequest } from "@/shared/api/request";
|
||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import type { ApiPage, PageQuery } from "@/shared/api/types";
|
||||
|
||||
export interface RegistrationRewardConfigDto {
|
||||
appCode?: string;
|
||||
enabled: boolean;
|
||||
rewardType: string;
|
||||
coinAmount: number;
|
||||
resourceGroupId: number;
|
||||
dailyLimit: number;
|
||||
updatedByAdminId?: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface RegistrationRewardConfigPayload {
|
||||
enabled: boolean;
|
||||
reward_type: string;
|
||||
coin_amount: number;
|
||||
resource_group_id: number;
|
||||
daily_limit: number;
|
||||
}
|
||||
|
||||
export interface RegistrationRewardClaimUserDto {
|
||||
userId?: number;
|
||||
displayUserId?: string;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface RegistrationRewardClaimDto {
|
||||
claimId: string;
|
||||
commandId?: string;
|
||||
userId: number;
|
||||
user?: RegistrationRewardClaimUserDto;
|
||||
rewardDay?: string;
|
||||
rewardType?: string;
|
||||
coinAmount?: number;
|
||||
resourceGroupId?: number;
|
||||
status?: string;
|
||||
walletCommandId?: string;
|
||||
walletTransactionId?: string;
|
||||
resourceGrantId?: string;
|
||||
failureReason?: string;
|
||||
claimedAtMs?: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
type RawConfig = RegistrationRewardConfigDto & Record<string, unknown>;
|
||||
type RawClaim = RegistrationRewardClaimDto & Record<string, unknown>;
|
||||
|
||||
export function getRegistrationRewardConfig(): Promise<RegistrationRewardConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.getRegistrationRewardConfig;
|
||||
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getRegistrationRewardConfig), {
|
||||
method: endpoint.method,
|
||||
}).then(normalizeConfig);
|
||||
}
|
||||
|
||||
export function updateRegistrationRewardConfig(
|
||||
payload: RegistrationRewardConfigPayload,
|
||||
): Promise<RegistrationRewardConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.updateRegistrationRewardConfig;
|
||||
return apiRequest<RawConfig, RegistrationRewardConfigPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateRegistrationRewardConfig),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
).then(normalizeConfig);
|
||||
}
|
||||
|
||||
export function listRegistrationRewardClaims(query: PageQuery = {}): Promise<ApiPage<RegistrationRewardClaimDto>> {
|
||||
const endpoint = API_ENDPOINTS.listRegistrationRewardClaims;
|
||||
return apiRequest<ApiPage<RawClaim>>(apiEndpointPath(API_OPERATIONS.listRegistrationRewardClaims), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
}).then((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map(normalizeClaim),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeConfig(item: RawConfig): RegistrationRewardConfigDto {
|
||||
return {
|
||||
appCode: stringValue(item.appCode ?? item.app_code),
|
||||
enabled: Boolean(item.enabled),
|
||||
rewardType: stringValue(item.rewardType ?? item.reward_type) || "coin",
|
||||
coinAmount: numberValue(item.coinAmount ?? item.coin_amount),
|
||||
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
||||
dailyLimit: numberValue(item.dailyLimit ?? item.daily_limit),
|
||||
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
|
||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeClaim(item: RawClaim): RegistrationRewardClaimDto {
|
||||
const rawUser = (item.user || {}) as Record<string, unknown>;
|
||||
return {
|
||||
claimId: stringValue(item.claimId ?? item.claim_id),
|
||||
commandId: stringValue(item.commandId ?? item.command_id),
|
||||
userId: numberValue(item.userId ?? item.user_id),
|
||||
user: {
|
||||
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
||||
username: stringValue(rawUser.username),
|
||||
avatar: stringValue(rawUser.avatar),
|
||||
},
|
||||
rewardDay: stringValue(item.rewardDay ?? item.reward_day),
|
||||
rewardType: stringValue(item.rewardType ?? item.reward_type),
|
||||
coinAmount: numberValue(item.coinAmount ?? item.coin_amount),
|
||||
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
||||
status: stringValue(item.status),
|
||||
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
|
||||
walletTransactionId: stringValue(item.walletTransactionId ?? item.wallet_transaction_id),
|
||||
resourceGrantId: stringValue(item.resourceGrantId ?? item.resource_grant_id),
|
||||
failureReason: stringValue(item.failureReason ?? item.failure_reason),
|
||||
claimedAtMs: numberValue(item.claimedAtMs ?? item.claimed_at_ms),
|
||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@ -0,0 +1,156 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { listResourceGroups } from "@/features/resources/api";
|
||||
import {
|
||||
getRegistrationRewardConfig,
|
||||
listRegistrationRewardClaims,
|
||||
updateRegistrationRewardConfig,
|
||||
} from "@/features/registration-reward/api";
|
||||
import { useRegistrationRewardAbilities } from "@/features/registration-reward/permissions.js";
|
||||
|
||||
const pageSize = 10;
|
||||
const emptyClaims = { items: [], page: 1, pageSize, total: 0 };
|
||||
|
||||
const emptyForm = {
|
||||
coinAmount: "",
|
||||
dailyLimit: "0",
|
||||
enabled: false,
|
||||
resourceGroupId: "",
|
||||
rewardType: "coin",
|
||||
};
|
||||
|
||||
export function useRegistrationRewardPage() {
|
||||
const abilities = useRegistrationRewardAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
const [resourceGroups, setResourceGroups] = useState([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const filters = useMemo(() => ({ keyword: query, status }), [query, status]);
|
||||
const {
|
||||
data: claims = emptyClaims,
|
||||
error: claimsError,
|
||||
loading: claimsLoading,
|
||||
reload: reloadClaims,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载领取记录失败",
|
||||
fetcher: listRegistrationRewardClaims,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["registration-reward-claims", filters, page],
|
||||
});
|
||||
|
||||
const reloadConfig = useCallback(async () => {
|
||||
setConfigLoading(true);
|
||||
try {
|
||||
const [config, groups] = await Promise.all([
|
||||
getRegistrationRewardConfig(),
|
||||
listResourceGroups({ page: 1, page_size: 100, status: "active" }),
|
||||
]);
|
||||
setForm(formFromConfig(config));
|
||||
setResourceGroups(groups.items || []);
|
||||
} catch (err) {
|
||||
showToast(err.message || "加载注册奖励配置失败", "error");
|
||||
} finally {
|
||||
setConfigLoading(false);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadConfig();
|
||||
}, [reloadConfig]);
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitConfig = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!abilities.canUpdate) {
|
||||
return;
|
||||
}
|
||||
setConfigSaving(true);
|
||||
try {
|
||||
const saved = await updateRegistrationRewardConfig(payloadFromForm(form));
|
||||
setForm(formFromConfig(saved));
|
||||
showToast("注册奖励配置已保存", "success");
|
||||
await reloadClaims();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存注册奖励配置失败", "error");
|
||||
} finally {
|
||||
setConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
changeQuery,
|
||||
changeStatus,
|
||||
claims,
|
||||
claimsError,
|
||||
claimsLoading,
|
||||
configLoading,
|
||||
configSaving,
|
||||
form,
|
||||
page,
|
||||
query,
|
||||
reloadClaims,
|
||||
reloadConfig,
|
||||
resetFilters,
|
||||
resourceGroups,
|
||||
setForm,
|
||||
setPage,
|
||||
status,
|
||||
submitConfig,
|
||||
};
|
||||
}
|
||||
|
||||
function formFromConfig(config) {
|
||||
return {
|
||||
coinAmount: config.coinAmount ? String(config.coinAmount) : "",
|
||||
dailyLimit: String(config.dailyLimit || 0),
|
||||
enabled: Boolean(config.enabled),
|
||||
resourceGroupId: config.resourceGroupId ? String(config.resourceGroupId) : "",
|
||||
rewardType: config.rewardType || "coin",
|
||||
};
|
||||
}
|
||||
|
||||
function payloadFromForm(form) {
|
||||
const rewardType = form.rewardType === "resource_group" ? "resource_group" : "coin";
|
||||
const coinAmount = Number(form.coinAmount || 0);
|
||||
const resourceGroupId = Number(form.resourceGroupId || 0);
|
||||
const dailyLimit = Number(form.dailyLimit || 0);
|
||||
if (dailyLimit < 0) {
|
||||
throw new Error("每日限制不能小于 0");
|
||||
}
|
||||
if (rewardType === "coin" && coinAmount <= 0) {
|
||||
throw new Error("金币奖励必须大于 0");
|
||||
}
|
||||
if (rewardType === "resource_group" && resourceGroupId <= 0) {
|
||||
throw new Error("请选择资源组");
|
||||
}
|
||||
return {
|
||||
coin_amount: rewardType === "coin" ? coinAmount : 0,
|
||||
daily_limit: dailyLimit,
|
||||
enabled: Boolean(form.enabled),
|
||||
resource_group_id: rewardType === "resource_group" ? resourceGroupId : 0,
|
||||
reward_type: rewardType,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,278 @@
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { useRegistrationRewardPage } from "@/features/registration-reward/hooks/useRegistrationRewardPage.js";
|
||||
import styles from "@/features/registration-reward/registration-reward.module.css";
|
||||
|
||||
const rewardTypeOptions = [
|
||||
["coin", "金币"],
|
||||
["resource_group", "资源组"],
|
||||
];
|
||||
|
||||
const claimStatusOptions = [
|
||||
["pending", "发放中"],
|
||||
["granted", "已发放"],
|
||||
["failed", "发放失败"],
|
||||
];
|
||||
|
||||
const columnsBase = [
|
||||
{
|
||||
key: "user",
|
||||
label: "用户信息",
|
||||
width: "minmax(260px, 1.2fr)",
|
||||
render: (claim) => <ClaimUser claim={claim} />,
|
||||
},
|
||||
{
|
||||
key: "reward",
|
||||
label: "奖励",
|
||||
width: "minmax(180px, 0.85fr)",
|
||||
render: (claim) => <RewardSummary claim={claim} />,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(120px, 0.55fr)",
|
||||
render: (claim) => claimStatusLabel(claim.status),
|
||||
},
|
||||
{
|
||||
key: "claimedAt",
|
||||
label: "领取时间",
|
||||
width: "minmax(180px, 0.85fr)",
|
||||
render: (claim) => formatMillis(claim.claimedAtMs || claim.createdAtMs),
|
||||
},
|
||||
{
|
||||
key: "claimId",
|
||||
label: "领取记录",
|
||||
width: "minmax(240px, 1fr)",
|
||||
render: (claim) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{claim.claimId}</span>
|
||||
<span className={styles.meta}>{claim.rewardDay || "-"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "failure",
|
||||
label: "失败原因",
|
||||
width: "minmax(180px, 0.85fr)",
|
||||
render: (claim) => claim.failureReason || "-",
|
||||
},
|
||||
];
|
||||
|
||||
export function RegistrationRewardPage() {
|
||||
const page = useRegistrationRewardPage();
|
||||
const total = page.claims.total || 0;
|
||||
const columns = columnsBase.map((column) => {
|
||||
if (column.key === "user") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户 ID、短号、名称",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: [["", "全部状态"], ...claimStatusOptions],
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<form className={styles.configPanel} onSubmit={page.submitConfig}>
|
||||
<div className={styles.configHeader}>
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.title}>注册奖励配置</span>
|
||||
<span className={styles.meta}>注册成功后由服务端自动发放</span>
|
||||
</div>
|
||||
<div className={styles.configActions}>
|
||||
<AdminActionIconButton
|
||||
disabled={page.configLoading}
|
||||
label="刷新配置"
|
||||
type="button"
|
||||
onClick={page.reloadConfig}
|
||||
>
|
||||
<RefreshOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<Button
|
||||
disabled={!page.abilities.canUpdate || page.configLoading || page.configSaving}
|
||||
startIcon={<SaveOutlined fontSize="small" />}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.configSections}>
|
||||
<section className={styles.configSection}>
|
||||
<div className={styles.configSectionTitle}>发放状态</div>
|
||||
<div className={styles.configGrid}>
|
||||
<AdminSwitch
|
||||
checked={page.form.enabled}
|
||||
checkedLabel="开启"
|
||||
disabled={!page.abilities.canUpdate || page.configLoading}
|
||||
label="注册奖励状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) =>
|
||||
page.setForm((current) => ({ ...current, enabled: event.target.checked }))
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate || page.configLoading}
|
||||
inputProps={{ min: 0 }}
|
||||
label="每日限制份数"
|
||||
type="number"
|
||||
value={page.form.dailyLimit}
|
||||
onChange={(event) =>
|
||||
page.setForm((current) => ({ ...current, dailyLimit: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.configSection}>
|
||||
<div className={styles.configSectionTitle}>奖励内容</div>
|
||||
<div className={styles.configGrid}>
|
||||
<TextField
|
||||
select
|
||||
disabled={!page.abilities.canUpdate || page.configLoading}
|
||||
label="奖励类型"
|
||||
value={page.form.rewardType}
|
||||
onChange={(event) =>
|
||||
page.setForm((current) => ({ ...current, rewardType: event.target.value }))
|
||||
}
|
||||
>
|
||||
{rewardTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{page.form.rewardType === "resource_group" ? (
|
||||
<TextField
|
||||
select
|
||||
disabled={!page.abilities.canUpdate || page.configLoading}
|
||||
label="资源组"
|
||||
value={page.form.resourceGroupId}
|
||||
onChange={(event) =>
|
||||
page.setForm((current) => ({
|
||||
...current,
|
||||
resourceGroupId: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<MenuItem value="">请选择资源组</MenuItem>
|
||||
{page.resourceGroups.map((group) => (
|
||||
<MenuItem key={group.groupId} value={String(group.groupId)}>
|
||||
{group.name || group.groupCode || group.groupId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : (
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate || page.configLoading}
|
||||
inputProps={{ min: 1 }}
|
||||
label="金币数量"
|
||||
type="number"
|
||||
value={page.form.coinAmount}
|
||||
onChange={(event) =>
|
||||
page.setForm((current) => ({ ...current, coinAmount: event.target.value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
<AdminActionIconButton label="刷新记录" type="button" onClick={page.reloadClaims}>
|
||||
<RefreshOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
}
|
||||
/>
|
||||
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={page.claims.items || []}
|
||||
minWidth="1240px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.claims.pageSize || 10,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(claim) => claim.claimId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function ClaimUser({ claim }) {
|
||||
const user = claim.user || {};
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<Avatar alt={user.username || String(claim.userId)} src={user.avatar || ""} sx={{ width: 36, height: 36 }} />
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.name}>{user.username || `用户 ${claim.userId}`}</span>
|
||||
<span className={styles.meta}>{user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${claim.userId}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RewardSummary({ claim }) {
|
||||
if (claim.rewardType === "resource_group") {
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>资源组</span>
|
||||
<span className={styles.meta}>#{claim.resourceGroupId || "-"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>{formatNumber(claim.coinAmount)} 金币</span>
|
||||
<span className={styles.meta}>注册奖励</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function claimStatusLabel(status) {
|
||||
return claimStatusOptions.find(([value]) => value === status)?.[1] || status || "-";
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
}
|
||||
11
src/features/registration-reward/permissions.js
Normal file
11
src/features/registration-reward/permissions.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export function useRegistrationRewardAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canUpdate: can(PERMISSIONS.registrationRewardUpdate),
|
||||
canView: can(PERMISSIONS.registrationRewardView),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
.configPanel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.configHeader {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.configActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.configSections {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.configSection {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.configSection + .configSection {
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.configSectionTitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.configGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.switchField {
|
||||
min-height: var(--control-height);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.title,
|
||||
.name {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta {
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.configHeader {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.configGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
12
src/features/registration-reward/routes.js
Normal file
12
src/features/registration-reward/routes.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const registrationRewardRoutes = [
|
||||
{
|
||||
label: "注册奖励",
|
||||
loader: () => import("./pages/RegistrationRewardPage.jsx").then((module) => module.RegistrationRewardPage),
|
||||
menuCode: MENU_CODES.registrationReward,
|
||||
pageKey: "registration-reward",
|
||||
path: "/activities/registration-reward",
|
||||
permission: PERMISSIONS.registrationRewardView,
|
||||
},
|
||||
];
|
||||
@ -115,6 +115,15 @@ export interface ResourceGrantItemDto {
|
||||
walletTransactionId?: string;
|
||||
}
|
||||
|
||||
export interface ResourceGrantOperatorDto {
|
||||
avatar?: string;
|
||||
displayUserId?: string;
|
||||
name?: string;
|
||||
source?: string;
|
||||
userId?: number | string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface ResourceGrantDto {
|
||||
appCode?: string;
|
||||
commandId?: string;
|
||||
@ -123,6 +132,7 @@ export interface ResourceGrantDto {
|
||||
grantSource?: string;
|
||||
grantSubjectId?: string;
|
||||
grantSubjectType?: string;
|
||||
operator?: ResourceGrantOperatorDto;
|
||||
items?: ResourceGrantItemDto[];
|
||||
operatorUserId?: number;
|
||||
reason?: string;
|
||||
|
||||
@ -26,6 +26,7 @@ export const resourceGrantSubjectLabels = {
|
||||
export const resourceTypeFilters = [
|
||||
["", "全部类型"],
|
||||
["avatar_frame", "头像框"],
|
||||
["profile_card", "资料卡"],
|
||||
["coin", "金币"],
|
||||
["vehicle", "座驾"],
|
||||
["chat_bubble", "气泡"],
|
||||
|
||||
@ -85,6 +85,28 @@ const emptyGiftForm = (gift = {}) => ({
|
||||
resourceId: gift.resourceId ? String(gift.resourceId) : "",
|
||||
sortOrder: gift.sortOrder === 0 || gift.sortOrder ? String(gift.sortOrder) : "0",
|
||||
});
|
||||
|
||||
export function applyGiftResourceSelection(form, resource) {
|
||||
const resourceId = resource?.resourceId ? String(resource.resourceId) : "";
|
||||
return {
|
||||
...form,
|
||||
giftId: resourceId,
|
||||
name: resource?.name || resource?.resourceCode || resourceId,
|
||||
resourceId,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyGiftPriceDefaults(form, coinPrice) {
|
||||
const priceValue = String(coinPrice ?? "");
|
||||
const priceNumber = Number(priceValue);
|
||||
return {
|
||||
...form,
|
||||
coinPrice: priceValue,
|
||||
giftPointAmount: priceValue,
|
||||
heatValue: priceValue.trim() && Number.isFinite(priceNumber) ? String(Math.ceil(priceNumber / 100)) : "",
|
||||
};
|
||||
}
|
||||
|
||||
const emptyGrantForm = () => ({
|
||||
durationDays: "0",
|
||||
groupId: "",
|
||||
@ -502,6 +524,20 @@ export function useGiftListPage() {
|
||||
setSelectedGift(null);
|
||||
};
|
||||
|
||||
const selectGiftResource = (resourceId) => {
|
||||
const selectedResource = resourceOptions.find((resource) => String(resource.resourceId) === String(resourceId));
|
||||
setForm((currentForm) => {
|
||||
if (activeAction !== "create") {
|
||||
return { ...currentForm, resourceId };
|
||||
}
|
||||
return applyGiftResourceSelection(currentForm, selectedResource || { resourceId });
|
||||
});
|
||||
};
|
||||
|
||||
const changeGiftPrice = (coinPrice) => {
|
||||
setForm((currentForm) => applyGiftPriceDefaults(currentForm, coinPrice));
|
||||
};
|
||||
|
||||
const submitGift = async (event) => {
|
||||
event.preventDefault();
|
||||
const editing = activeAction === "edit";
|
||||
@ -560,6 +596,7 @@ export function useGiftListPage() {
|
||||
...sharedPageState({ page, query, result, setPage, setQuery }),
|
||||
abilities,
|
||||
activeAction,
|
||||
changeGiftPrice,
|
||||
changeRegionId: resetSetter(setRegionId, setPage),
|
||||
changeStatus: resetSetter(setStatus, setPage),
|
||||
closeAction,
|
||||
@ -574,6 +611,7 @@ export function useGiftListPage() {
|
||||
resourceOptionsLoading,
|
||||
selectedGift,
|
||||
resetFilters,
|
||||
selectGiftResource,
|
||||
setForm,
|
||||
status,
|
||||
submitGift,
|
||||
|
||||
44
src/features/resources/hooks/useResourcePages.test.js
Normal file
44
src/features/resources/hooks/useResourcePages.test.js
Normal file
@ -0,0 +1,44 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { applyGiftPriceDefaults, applyGiftResourceSelection } from "./useResourcePages.js";
|
||||
|
||||
test("fills gift name and id from selected gift resource", () => {
|
||||
const form = {
|
||||
coinPrice: "10",
|
||||
giftId: "",
|
||||
name: "",
|
||||
resourceId: "",
|
||||
};
|
||||
|
||||
const nextForm = applyGiftResourceSelection(form, {
|
||||
name: "Rose",
|
||||
resourceCode: "gift_rose",
|
||||
resourceId: 11,
|
||||
});
|
||||
|
||||
expect(nextForm).toMatchObject({
|
||||
coinPrice: "10",
|
||||
giftId: "11",
|
||||
name: "Rose",
|
||||
resourceId: "11",
|
||||
});
|
||||
});
|
||||
|
||||
test("fills gift points and heat from gift price", () => {
|
||||
const form = {
|
||||
coinPrice: "",
|
||||
giftPointAmount: "0",
|
||||
heatValue: "0",
|
||||
name: "Rose",
|
||||
};
|
||||
|
||||
expect(applyGiftPriceDefaults(form, "100")).toMatchObject({
|
||||
coinPrice: "100",
|
||||
giftPointAmount: "100",
|
||||
heatValue: "1",
|
||||
});
|
||||
expect(applyGiftPriceDefaults(form, "101")).toMatchObject({
|
||||
coinPrice: "101",
|
||||
giftPointAmount: "101",
|
||||
heatValue: "2",
|
||||
});
|
||||
});
|
||||
@ -6,26 +6,28 @@ import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Popover from "@mui/material/Popover";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo, useState } from "react";
|
||||
import { AdminFormDialog, AdminFormFieldGrid } from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import {
|
||||
AdminFormDialog,
|
||||
AdminFormFieldGrid,
|
||||
AdminFormSection,
|
||||
AdminFormSwitchField,
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminRowActions,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
@ -182,12 +184,6 @@ export function GiftListPage() {
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.query && !page.status && !page.regionId}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreateGift ? (
|
||||
<AdminActionIconButton label="添加礼物" primary onClick={page.openCreateGift}>
|
||||
@ -198,15 +194,22 @@ export function GiftListPage() {
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable columns={tableColumns} items={items} minWidth="1480px" rowKey={(gift) => gift.giftId} />
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
items={items}
|
||||
minWidth="1480px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.data.pageSize || 10,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(gift) => gift.giftId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<GiftFormDialog
|
||||
@ -241,7 +244,7 @@ function GiftRowActions({ gift, page }) {
|
||||
function GiftStatusSwitch({ gift, page }) {
|
||||
const checked = gift.status === "active";
|
||||
return (
|
||||
<Switch
|
||||
<AdminSwitch
|
||||
checked={checked}
|
||||
disabled={!page.abilities.canStatusGift || page.loadingAction === `gift-status-${gift.giftId}`}
|
||||
inputProps={{ "aria-label": checked ? "禁用礼物" : "启用礼物" }}
|
||||
@ -252,6 +255,7 @@ function GiftStatusSwitch({ gift, page }) {
|
||||
|
||||
function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page, regionOptions }) {
|
||||
const submitDisabled = disabled || loading || page.resourceOptionsLoading || page.loadingRegions;
|
||||
const showGiftIdentityFields = mode === "edit" || Boolean(form.resourceId);
|
||||
return (
|
||||
<AdminFormDialog
|
||||
loading={loading}
|
||||
@ -262,128 +266,140 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="礼物名称"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(event) => page.setForm({ ...form, name: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled || mode === "edit"}
|
||||
label="礼物 ID"
|
||||
required
|
||||
value={form.giftId}
|
||||
onChange={(event) => page.setForm({ ...form, giftId: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="礼物类型"
|
||||
required
|
||||
select
|
||||
value={form.giftTypeCode}
|
||||
onChange={(event) => page.setForm({ ...form, giftTypeCode: event.target.value })}
|
||||
>
|
||||
{giftTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled || page.resourceOptionsLoading}
|
||||
label="礼物资源"
|
||||
required
|
||||
select
|
||||
value={form.resourceId}
|
||||
onChange={(event) => page.setForm({ ...form, resourceId: event.target.value })}
|
||||
>
|
||||
{page.resourceOptions.map((resource) => (
|
||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
||||
{resource.name || resource.resourceCode || resource.resourceId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="收费类型"
|
||||
required
|
||||
select
|
||||
value={form.chargeAssetType}
|
||||
onChange={(event) => page.setForm({ ...form, chargeAssetType: event.target.value })}
|
||||
>
|
||||
{chargeAssetOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="价格"
|
||||
required
|
||||
type="number"
|
||||
value={form.coinPrice}
|
||||
onChange={(event) => page.setForm({ ...form, coinPrice: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="积分"
|
||||
type="number"
|
||||
value={form.giftPointAmount}
|
||||
onChange={(event) => page.setForm({ ...form, giftPointAmount: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="热度"
|
||||
type="number"
|
||||
value={form.heatValue}
|
||||
onChange={(event) => page.setForm({ ...form, heatValue: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
|
||||
/>
|
||||
<GiftRegionSelect
|
||||
disabled={disabled || page.loadingRegions}
|
||||
labels={buildRegionLabelMap(regionOptions)}
|
||||
options={regionOptions}
|
||||
value={form.regionIds}
|
||||
onChange={(regionIds) => page.setForm({ ...form, regionIds })}
|
||||
/>
|
||||
<DateTimeField
|
||||
disabled={disabled}
|
||||
label="有效开始时间"
|
||||
value={form.effectiveFrom}
|
||||
onChange={(effectiveFrom) => page.setForm({ ...form, effectiveFrom })}
|
||||
/>
|
||||
<DateTimeField
|
||||
disabled={disabled}
|
||||
label="有效结束时间"
|
||||
value={form.effectiveTo}
|
||||
onChange={(effectiveTo) => page.setForm({ ...form, effectiveTo })}
|
||||
/>
|
||||
<GiftEffectSelect
|
||||
disabled={disabled}
|
||||
value={form.effectTypes}
|
||||
onChange={(effectTypes) => page.setForm({ ...form, effectTypes })}
|
||||
/>
|
||||
<FormControlLabel
|
||||
className={styles.giftSwitch}
|
||||
control={
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
disabled={disabled}
|
||||
onChange={(event) => page.setForm({ ...form, enabled: event.target.checked })}
|
||||
/>
|
||||
}
|
||||
label={form.enabled ? "启用" : "禁用"}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
<AdminFormSection title="资源信息">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={disabled || page.resourceOptionsLoading}
|
||||
label="礼物资源"
|
||||
required
|
||||
select
|
||||
value={form.resourceId}
|
||||
onChange={(event) => page.selectGiftResource(event.target.value)}
|
||||
>
|
||||
{page.resourceOptions.map((resource) => (
|
||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
||||
{resource.name || resource.resourceCode || resource.resourceId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{showGiftIdentityFields ? (
|
||||
<>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="礼物名称"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(event) => page.setForm({ ...form, name: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled || mode === "edit"}
|
||||
label="礼物 ID"
|
||||
required
|
||||
value={form.giftId}
|
||||
onChange={(event) => page.setForm({ ...form, giftId: event.target.value })}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="礼物类型"
|
||||
required
|
||||
select
|
||||
value={form.giftTypeCode}
|
||||
onChange={(event) => page.setForm({ ...form, giftTypeCode: event.target.value })}
|
||||
>
|
||||
{giftTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="价格与排序">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="收费类型"
|
||||
required
|
||||
select
|
||||
value={form.chargeAssetType}
|
||||
onChange={(event) => page.setForm({ ...form, chargeAssetType: event.target.value })}
|
||||
>
|
||||
{chargeAssetOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="价格"
|
||||
required
|
||||
type="number"
|
||||
value={form.coinPrice}
|
||||
onChange={(event) => page.changeGiftPrice(event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="积分"
|
||||
type="number"
|
||||
value={form.giftPointAmount}
|
||||
onChange={(event) => page.setForm({ ...form, giftPointAmount: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="热度"
|
||||
type="number"
|
||||
value={form.heatValue}
|
||||
onChange={(event) => page.setForm({ ...form, heatValue: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={form.enabled}
|
||||
checkedLabel="启用"
|
||||
className={styles.giftSwitch}
|
||||
disabled={disabled}
|
||||
label="礼物状态"
|
||||
uncheckedLabel="禁用"
|
||||
onChange={(checked) => page.setForm({ ...form, enabled: checked })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="投放与时间">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<GiftRegionSelect
|
||||
disabled={disabled || page.loadingRegions}
|
||||
labels={buildRegionLabelMap(regionOptions)}
|
||||
options={regionOptions}
|
||||
value={form.regionIds}
|
||||
onChange={(regionIds) => page.setForm({ ...form, regionIds })}
|
||||
/>
|
||||
<DateTimeField
|
||||
disabled={disabled}
|
||||
label="有效开始时间"
|
||||
value={form.effectiveFrom}
|
||||
onChange={(effectiveFrom) => page.setForm({ ...form, effectiveFrom })}
|
||||
/>
|
||||
<DateTimeField
|
||||
disabled={disabled}
|
||||
label="有效结束时间"
|
||||
value={form.effectiveTo}
|
||||
onChange={(effectiveTo) => page.setForm({ ...form, effectiveTo })}
|
||||
/>
|
||||
<GiftEffectSelect
|
||||
disabled={disabled}
|
||||
value={form.effectTypes}
|
||||
onChange={(effectTypes) => page.setForm({ ...form, effectTypes })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,17 +1,16 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||
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,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
} 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 {
|
||||
@ -40,6 +39,12 @@ const grantColumns = [
|
||||
width: "minmax(120px, 0.7fr)",
|
||||
render: (grant) => grant.targetUserId || "-",
|
||||
},
|
||||
{
|
||||
key: "operator",
|
||||
label: "操作人",
|
||||
width: "minmax(180px, 1fr)",
|
||||
render: (grant) => <GrantOperator grant={grant} />,
|
||||
},
|
||||
{
|
||||
key: "subject",
|
||||
label: "对象",
|
||||
@ -114,9 +119,6 @@ export function ResourceGrantListPage() {
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<AdminFilterResetButton disabled={!page.query && !page.status} onClick={page.resetFilters} />
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreateGrant ? (
|
||||
<AdminActionIconButton label="资源赠送" primary onClick={page.openCreateGrant}>
|
||||
@ -130,17 +132,19 @@ export function ResourceGrantListPage() {
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
minWidth="1190px"
|
||||
minWidth="1370px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.data.pageSize || 10,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(grant) => grant.grantId}
|
||||
/>
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<ResourceGrantDialog
|
||||
@ -181,82 +185,92 @@ function ResourceGrantDialog({
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="用户 ID"
|
||||
required
|
||||
type="number"
|
||||
value={form.targetUserId}
|
||||
onChange={(event) => setForm({ ...form, targetUserId: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="对象类型"
|
||||
required
|
||||
select
|
||||
value={form.subjectType}
|
||||
onChange={(event) => setForm({ ...form, groupId: "", resourceId: "", subjectType: event.target.value })}
|
||||
>
|
||||
<MenuItem value="resource">资源</MenuItem>
|
||||
<MenuItem value="resource_group">资源组</MenuItem>
|
||||
</TextField>
|
||||
{form.subjectType === "resource" ? (
|
||||
<>
|
||||
<AdminFormSection title="赠送对象">
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="用户 ID"
|
||||
required
|
||||
type="number"
|
||||
value={form.targetUserId}
|
||||
onChange={(event) => setForm({ ...form, targetUserId: event.target.value })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="赠送内容">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
disabled={disabled || optionsLoading}
|
||||
label="资源"
|
||||
disabled={disabled}
|
||||
label="对象类型"
|
||||
required
|
||||
select
|
||||
value={form.resourceId}
|
||||
onChange={(event) => setForm({ ...form, resourceId: event.target.value })}
|
||||
value={form.subjectType}
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, groupId: "", resourceId: "", subjectType: event.target.value })
|
||||
}
|
||||
>
|
||||
{resourceOptions.map((resource) => (
|
||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
||||
{resource.name || resource.resourceCode || resource.resourceId}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem value="resource">资源</MenuItem>
|
||||
<MenuItem value="resource_group">资源组</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="数量"
|
||||
required
|
||||
type="number"
|
||||
value={form.quantity}
|
||||
onChange={(event) => setForm({ ...form, quantity: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="有效天数"
|
||||
type="number"
|
||||
value={form.durationDays}
|
||||
onChange={(event) => setForm({ ...form, durationDays: event.target.value })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
{form.subjectType === "resource" ? (
|
||||
<>
|
||||
<TextField
|
||||
disabled={disabled || optionsLoading}
|
||||
label="资源"
|
||||
required
|
||||
select
|
||||
value={form.resourceId}
|
||||
onChange={(event) => setForm({ ...form, resourceId: event.target.value })}
|
||||
>
|
||||
{resourceOptions.map((resource) => (
|
||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
||||
{resource.name || resource.resourceCode || resource.resourceId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="数量"
|
||||
required
|
||||
type="number"
|
||||
value={form.quantity}
|
||||
onChange={(event) => setForm({ ...form, quantity: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="有效天数"
|
||||
type="number"
|
||||
value={form.durationDays}
|
||||
onChange={(event) => setForm({ ...form, durationDays: event.target.value })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
disabled={disabled || optionsLoading}
|
||||
label="资源组"
|
||||
required
|
||||
select
|
||||
value={form.groupId}
|
||||
onChange={(event) => setForm({ ...form, groupId: event.target.value })}
|
||||
>
|
||||
{groupOptions.map((group) => (
|
||||
<MenuItem key={group.groupId} value={String(group.groupId)}>
|
||||
{group.name || group.groupCode || group.groupId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="备注">
|
||||
<TextField
|
||||
disabled={disabled || optionsLoading}
|
||||
label="资源组"
|
||||
disabled={disabled}
|
||||
label="原因"
|
||||
multiline
|
||||
minRows={2}
|
||||
required
|
||||
select
|
||||
value={form.groupId}
|
||||
onChange={(event) => setForm({ ...form, groupId: event.target.value })}
|
||||
>
|
||||
{groupOptions.map((group) => (
|
||||
<MenuItem key={group.groupId} value={String(group.groupId)}>
|
||||
{group.name || group.groupCode || group.groupId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="原因"
|
||||
multiline
|
||||
minRows={2}
|
||||
required
|
||||
value={form.reason}
|
||||
onChange={(event) => setForm({ ...form, reason: event.target.value })}
|
||||
/>
|
||||
value={form.reason}
|
||||
onChange={(event) => setForm({ ...form, reason: event.target.value })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
@ -279,6 +293,49 @@ function GrantItems({ grant }) {
|
||||
);
|
||||
}
|
||||
|
||||
function GrantOperator({ grant }) {
|
||||
const operator = grant.operator || {};
|
||||
const source = operator.source || grant.grantSource || "";
|
||||
const operatorUserId = operator.userId || grant.operatorUserId;
|
||||
if (source === "manager_center") {
|
||||
const name = operator.username || operator.name || (operatorUserId ? `用户 ${operatorUserId}` : "-");
|
||||
const shortId = operator.displayUserId || operatorUserId || "-";
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.operatorAvatar}>
|
||||
{operator.avatar ? (
|
||||
<img alt="" src={operator.avatar} />
|
||||
) : (
|
||||
<PersonOutlineOutlined fontSize="small" />
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.identityText}>
|
||||
<span className={styles.name}>{name}</span>
|
||||
<span className={styles.meta}>{shortId}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (source === "admin") {
|
||||
const name = operator.name || operator.username || (operatorUserId ? `Admin ${operatorUserId}` : "-");
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.name}>{name}</span>
|
||||
<span className={styles.meta}>{operator.username || operatorUserId || "-"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!operatorUserId) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.name}>{operatorUserId}</span>
|
||||
<span className={styles.meta}>{source || "-"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(durationMs) {
|
||||
const days = Number(durationMs || 0) / (24 * 60 * 60 * 1000);
|
||||
return Number.isInteger(days) ? `${days}天` : `${days.toFixed(1)}天`;
|
||||
|
||||
@ -5,9 +5,8 @@ import DiamondOutlined from "@mui/icons-material/DiamondOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {
|
||||
AdminFormDialog,
|
||||
@ -16,19 +15,18 @@ import {
|
||||
AdminFormList,
|
||||
AdminFormListRow,
|
||||
AdminFormSection,
|
||||
AdminFormSwitchField,
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
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 { resourceGroupAssetLabels, resourceStatusFilters, resourceTypeLabels } from "@/features/resources/constants.js";
|
||||
@ -110,9 +108,6 @@ export function ResourceGroupListPage() {
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<AdminFilterResetButton disabled={!page.query && !page.status} onClick={page.resetFilters} />
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreateGroup ? (
|
||||
<AdminActionIconButton label="添加资源组" primary onClick={page.openCreateGroup}>
|
||||
@ -123,15 +118,22 @@ export function ResourceGroupListPage() {
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable columns={columns} items={items} minWidth="1060px" rowKey={(group) => group.groupId} />
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
minWidth="1060px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.data.pageSize || 10,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(group) => group.groupId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<ResourceGroupFormDialog
|
||||
@ -171,7 +173,7 @@ function ResourceGroupRowActions({ group, page }) {
|
||||
function ResourceGroupStatusSwitch({ group, page }) {
|
||||
const checked = group.status === "active";
|
||||
return (
|
||||
<Switch
|
||||
<AdminSwitch
|
||||
checked={checked}
|
||||
disabled={!page.abilities.canUpdateGroup || page.loadingAction === `group-status-${group.groupId}`}
|
||||
inputProps={{ "aria-label": checked ? "禁用资源组" : "启用资源组" }}
|
||||
@ -192,62 +194,67 @@ function ResourceGroupFormDialog({ disabled, form, loading, mode, onClose, onSub
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<AdminFormFieldGrid columns="minmax(210px, 1fr) minmax(210px, 1fr) 112px">
|
||||
<TextField
|
||||
label="资源组名称"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(event) => page.setForm({ ...form, name: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="资源组编码"
|
||||
required
|
||||
value={form.groupCode}
|
||||
onChange={(event) => page.setForm({ ...form, groupCode: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
<TextField
|
||||
label="说明"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={form.description}
|
||||
onChange={(event) => page.setForm({ ...form, description: event.target.value })}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
onChange={(event) => page.setForm({ ...form, enabled: event.target.checked })}
|
||||
<AdminFormSection title="基础信息">
|
||||
<AdminFormFieldGrid columns="minmax(210px, 1fr) minmax(210px, 1fr) 112px">
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="资源组名称"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(event) => page.setForm({ ...form, name: event.target.value })}
|
||||
/>
|
||||
}
|
||||
label={form.enabled ? "启用" : "禁用"}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="资源组编码"
|
||||
required
|
||||
value={form.groupCode}
|
||||
onChange={(event) => page.setForm({ ...form, groupCode: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="说明"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={form.description}
|
||||
onChange={(event) => page.setForm({ ...form, description: event.target.value })}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={form.enabled}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label="资源组状态"
|
||||
uncheckedLabel="禁用"
|
||||
onChange={(checked) => page.setForm({ ...form, enabled: checked })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection
|
||||
title="组内资源"
|
||||
actions={
|
||||
<AdminFormInlineActions>
|
||||
<Button
|
||||
disabled={loading}
|
||||
disabled={disabled || loading}
|
||||
onClick={page.addResourceItem}
|
||||
startIcon={<Inventory2Outlined fontSize="small" />}
|
||||
>
|
||||
资源
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading}
|
||||
disabled={disabled || loading}
|
||||
onClick={() => page.addWalletAssetItem("COIN")}
|
||||
startIcon={<MonetizationOnOutlined fontSize="small" />}
|
||||
>
|
||||
金币
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading}
|
||||
disabled={disabled || loading}
|
||||
onClick={() => page.addWalletAssetItem("DIAMOND")}
|
||||
startIcon={<DiamondOutlined fontSize="small" />}
|
||||
>
|
||||
@ -259,7 +266,7 @@ function ResourceGroupFormDialog({ disabled, form, loading, mode, onClose, onSub
|
||||
<AdminFormList>
|
||||
{form.items.map((item, index) => (
|
||||
<ResourceGroupItemEditor
|
||||
disabled={loading}
|
||||
disabled={disabled || loading}
|
||||
index={index}
|
||||
item={item}
|
||||
key={`${item.itemType}-${item.walletAssetType || item.resourceId || index}-${index}`}
|
||||
|
||||
@ -1,23 +1,25 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { AdminFormDialog } 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 { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminRowActions,
|
||||
AdminListToolbar,
|
||||
} 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 {
|
||||
@ -121,12 +123,6 @@ export function ResourceListPage() {
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.query && !page.status && !page.resourceType}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreate ? (
|
||||
<AdminActionIconButton label="添加资源" primary onClick={page.openCreateResource}>
|
||||
@ -141,16 +137,18 @@ export function ResourceListPage() {
|
||||
columns={columns}
|
||||
items={items}
|
||||
minWidth="1120px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.data.pageSize || 10,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(resource) => resource.resourceId}
|
||||
/>
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<ResourceFormDialog
|
||||
@ -197,61 +195,74 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<TextField
|
||||
label="资源名称"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="资源编码"
|
||||
required
|
||||
value={form.resourceCode}
|
||||
onChange={(event) => setForm({ ...form, resourceCode: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="资源类型"
|
||||
required
|
||||
select
|
||||
value={form.resourceType}
|
||||
onChange={(event) => setForm({ ...form, resourceType: event.target.value, walletAssetAmount: "" })}
|
||||
>
|
||||
{resourceTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{form.resourceType === "coin" ? (
|
||||
<TextField
|
||||
label="金币数量"
|
||||
required
|
||||
type="number"
|
||||
value={form.walletAssetAmount}
|
||||
onChange={(event) => setForm({ ...form, walletAssetAmount: event.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
<UploadField
|
||||
disabled={uploadDisabled}
|
||||
label="资源封面"
|
||||
value={form.previewUrl}
|
||||
onChange={(previewUrl) => setForm({ ...form, previewUrl })}
|
||||
/>
|
||||
<UploadField
|
||||
disabled={uploadDisabled}
|
||||
label="资源素材"
|
||||
value={form.assetUrl}
|
||||
onChange={(assetUrl) => setForm({ ...form, assetUrl })}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
onChange={(event) => setForm({ ...form, enabled: event.target.checked })}
|
||||
<AdminFormSection title="基础信息">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="资源名称"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||
/>
|
||||
}
|
||||
label={form.enabled ? "启用" : "禁用"}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="资源编码"
|
||||
required
|
||||
value={form.resourceCode}
|
||||
onChange={(event) => setForm({ ...form, resourceCode: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="资源类型"
|
||||
required
|
||||
select
|
||||
value={form.resourceType}
|
||||
onChange={(event) => setForm({ ...form, resourceType: event.target.value, walletAssetAmount: "" })}
|
||||
>
|
||||
{resourceTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{form.resourceType === "coin" ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="金币数量"
|
||||
required
|
||||
type="number"
|
||||
value={form.walletAssetAmount}
|
||||
onChange={(event) => setForm({ ...form, walletAssetAmount: event.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="素材">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<UploadField
|
||||
disabled={disabled || uploadDisabled}
|
||||
label="资源封面"
|
||||
value={form.previewUrl}
|
||||
onChange={(previewUrl) => setForm({ ...form, previewUrl })}
|
||||
/>
|
||||
<UploadField
|
||||
disabled={disabled || uploadDisabled}
|
||||
label="资源素材"
|
||||
value={form.assetUrl}
|
||||
onChange={(assetUrl) => setForm({ ...form, assetUrl })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="状态">
|
||||
<AdminFormSwitchField
|
||||
checked={form.enabled}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label="资源状态"
|
||||
uncheckedLabel="禁用"
|
||||
onChange={(checked) => setForm({ ...form, enabled: checked })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
@ -289,7 +300,7 @@ function GrantInfo({ resource }) {
|
||||
function ResourceStatusSwitch({ page, resource }) {
|
||||
const checked = resource.status === "active";
|
||||
return (
|
||||
<Switch
|
||||
<AdminSwitch
|
||||
checked={checked}
|
||||
disabled={!page.abilities.canUpdate || page.loadingAction === `resource-status-${resource.resourceId}`}
|
||||
inputProps={{ "aria-label": checked ? "禁用资源" : "启用资源" }}
|
||||
|
||||
@ -25,6 +25,26 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.operatorAvatar {
|
||||
display: inline-flex;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card-strong);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.operatorAvatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.identityText,
|
||||
.stack {
|
||||
display: grid;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const resourceTypes = ["avatar_frame", "coin", "vehicle", "chat_bubble", "badge", "floating_screen", "gift"];
|
||||
const resourceTypes = ["avatar_frame", "profile_card", "coin", "vehicle", "chat_bubble", "badge", "floating_screen", "gift"];
|
||||
const walletAssetTypes = ["COIN", "DIAMOND"];
|
||||
const optionalWalletAssetTypeSchema = z
|
||||
.preprocess((value) => {
|
||||
|
||||
@ -1,8 +1,22 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
||||
import { giftFormSchema, resourceGrantFormSchema, resourceGroupCreateFormSchema } from "./schema.js";
|
||||
import { giftFormSchema, resourceFormSchema, resourceGrantFormSchema, resourceGroupCreateFormSchema } from "./schema.js";
|
||||
|
||||
describe("resource form schema", () => {
|
||||
test("allows profile card resources", () => {
|
||||
const payload = parseForm(resourceFormSchema, {
|
||||
assetUrl: "https://media.haiyihy.com/resource/profile-card.png",
|
||||
enabled: true,
|
||||
name: "Profile Card",
|
||||
previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png",
|
||||
resourceCode: "profile_card_gold",
|
||||
resourceType: "profile_card",
|
||||
walletAssetAmount: ""
|
||||
});
|
||||
|
||||
expect(payload.resourceType).toBe("profile_card");
|
||||
});
|
||||
|
||||
test("allows resource group resource items without wallet asset type", () => {
|
||||
const payload = parseForm(resourceGroupCreateFormSchema, {
|
||||
description: "",
|
||||
|
||||
@ -19,22 +19,30 @@ export function RoleFormDrawer({
|
||||
<Drawer anchor="right" open={open} onClose={onClose}>
|
||||
<form className="form-drawer form-drawer--wide" onSubmit={onSubmit}>
|
||||
<h2>{editingRole ? "编辑角色" : "新增角色"}</h2>
|
||||
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="角色名称" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
||||
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="角色编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
|
||||
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="说明" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
|
||||
{abilities.canLoadPermissions ? (
|
||||
<div className="permission-menu-list">
|
||||
{permissionGroups.map((group) => (
|
||||
<PermissionMenuGroup
|
||||
abilities={abilities}
|
||||
group={group}
|
||||
key={group.key}
|
||||
permissionIds={form.permissionIds}
|
||||
onTogglePermission={onTogglePermission}
|
||||
onTogglePermissionGroup={onTogglePermissionGroup}
|
||||
/>
|
||||
))}
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">基础信息</div>
|
||||
<div className="form-drawer__grid">
|
||||
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="角色名称" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
||||
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="角色编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
|
||||
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="说明" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
|
||||
</div>
|
||||
</section>
|
||||
{abilities.canLoadPermissions ? (
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">权限范围</div>
|
||||
<div className="permission-menu-list">
|
||||
{permissionGroups.map((group) => (
|
||||
<PermissionMenuGroup
|
||||
abilities={abilities}
|
||||
group={group}
|
||||
key={group.key}
|
||||
permissionIds={form.permissionIds}
|
||||
onTogglePermission={onTogglePermission}
|
||||
onTogglePermissionGroup={onTogglePermissionGroup}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<div className="form-drawer__actions">
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
|
||||
@ -25,11 +25,11 @@ const permissionGroupDefinitions = [
|
||||
{ key: "rooms", prefixes: ["room"], title: "房间管理" },
|
||||
{ key: "countries", prefixes: ["country"], title: "国家管理" },
|
||||
{ key: "regions", prefixes: ["region"], title: "区域管理" },
|
||||
{ key: "agencies", prefixes: ["agency"], title: "Agency 管理" },
|
||||
{ key: "bds", prefixes: ["bd"], title: "BD 管理" },
|
||||
{ key: "hosts", prefixes: ["host"], title: "主播管理" },
|
||||
{ key: "agencies", prefixes: ["agency"], title: "Agency 列表" },
|
||||
{ key: "bds", prefixes: ["bd"], title: "BD 列表" },
|
||||
{ key: "hosts", prefixes: ["host"], title: "主播列表" },
|
||||
{ key: "coin-sellers", prefixes: ["coin-seller"], title: "币商列表" },
|
||||
{ key: "logs", prefixes: ["log"], title: "日志审计" },
|
||||
{ key: "notifications", prefixes: ["notification"], title: "通知中心" },
|
||||
{ key: "jobs", prefixes: ["job", "export"], title: "任务中心" },
|
||||
{ key: "common", prefixes: ["upload"], title: "通用能力" },
|
||||
];
|
||||
|
||||
@ -2,7 +2,6 @@ import Add from "@mui/icons-material/Add";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
@ -18,9 +17,6 @@ export function RoleManagementPage() {
|
||||
<>
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<AdminFilterResetButton disabled={!page.query} onClick={page.resetFilters} />
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreate ? (
|
||||
<AdminActionIconButton label="新增角色" primary onClick={page.openCreate}>
|
||||
|
||||
@ -4,15 +4,17 @@ import type {
|
||||
ApiPage,
|
||||
EntityId,
|
||||
PageQuery,
|
||||
RoomConfigDto,
|
||||
RoomConfigPayload,
|
||||
RoomDto,
|
||||
RoomUpdatePayload
|
||||
RoomUpdatePayload,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
export function listRooms(query: PageQuery = {}): Promise<ApiPage<RoomDto>> {
|
||||
const endpoint = API_ENDPOINTS.listRooms;
|
||||
return apiRequest<ApiPage<RoomDto>>(apiEndpointPath(API_OPERATIONS.listRooms), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
@ -20,13 +22,28 @@ export function updateRoom(roomId: EntityId, payload: RoomUpdatePayload): Promis
|
||||
const endpoint = API_ENDPOINTS.updateRoom;
|
||||
return apiRequest<RoomDto, RoomUpdatePayload>(apiEndpointPath(API_OPERATIONS.updateRoom, { room_id: roomId }), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteRoom(roomId: EntityId): Promise<{ deleted: boolean }> {
|
||||
const endpoint = API_ENDPOINTS.deleteRoom;
|
||||
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteRoom, { room_id: roomId }), {
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function getRoomConfig(): Promise<RoomConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.getRoomConfig;
|
||||
return apiRequest<RoomConfigDto>(apiEndpointPath(API_OPERATIONS.getRoomConfig), {
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateRoomConfig(payload: RoomConfigPayload): Promise<RoomConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.updateRoomConfig;
|
||||
return apiRequest<RoomConfigDto, RoomConfigPayload>(apiEndpointPath(API_OPERATIONS.updateRoomConfig), {
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
|
||||
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
|
||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { roomStatusLabels } from "@/features/rooms/constants.js";
|
||||
@ -8,7 +10,7 @@ import styles from "@/features/rooms/rooms.module.css";
|
||||
export function RoomDetailDrawer({ onClose, open, room }) {
|
||||
const owner = room?.owner || {};
|
||||
const normalizedStatus = room?.status === "active" ? "active" : "closed";
|
||||
const ownerDisplayId = owner.displayUserId || room?.ownerUserId || "-";
|
||||
const ownerDisplayId = ownerLongShortId(owner, room);
|
||||
|
||||
return (
|
||||
<SideDrawer open={open} title="房间详情" width="wide" onClose={onClose}>
|
||||
@ -23,18 +25,17 @@ export function RoomDetailDrawer({ onClose, open, room }) {
|
||||
<h3>{room.title || "-"}</h3>
|
||||
<StatusBadge status={normalizedStatus} />
|
||||
</div>
|
||||
<span className={styles.detailMeta}>房间 ID {room.roomId}</span>
|
||||
<CopyableRoomId className={styles.detailMeta} prefix="房间 ID " roomId={room.roomId} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<DetailSection
|
||||
items={[
|
||||
["房间名称", room.title],
|
||||
["房间 ID", room.roomId],
|
||||
["房间 ID", <CopyableRoomId key="room-id" roomId={room.roomId} />],
|
||||
["房间状态", roomStatusLabels[normalizedStatus]],
|
||||
["房间模式", room.mode],
|
||||
["区域", room.regionName || formatRegion(room.visibleRegionId)],
|
||||
["可见区域 ID", room.visibleRegionId],
|
||||
["区域", room.regionName],
|
||||
["创建时间", formatMillis(room.createdAtMs)],
|
||||
["更新时间", formatMillis(room.updatedAtMs)]
|
||||
]}
|
||||
@ -53,8 +54,7 @@ export function RoomDetailDrawer({ onClose, open, room }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailGrid}>
|
||||
<DetailItem label="房主用户 ID" value={room.ownerUserId || owner.userId} />
|
||||
<DetailItem label="主持用户 ID" value={room.hostUserId} />
|
||||
<DetailItem label="房主长 ID(短 ID)" value={ownerDisplayId} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -63,6 +63,7 @@ export function RoomDetailDrawer({ onClose, open, room }) {
|
||||
["在线人数", `${formatNumber(room.onlineCount)} 人`],
|
||||
["座位数", `${formatNumber(room.seatCount)} 个`],
|
||||
["已占座位", `${formatNumber(room.occupiedSeatCount)} 个`],
|
||||
["房间贡献", formatNumber(roomContributionValue(room))],
|
||||
["热度", formatNumber(room.heat)],
|
||||
["排序分", formatNumber(room.sortScore)]
|
||||
]}
|
||||
@ -91,11 +92,46 @@ function DetailItem({ label, value }) {
|
||||
return (
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>{label}</span>
|
||||
<strong className={styles.detailValue}>{displayValue(value)}</strong>
|
||||
<div className={styles.detailValue}>{displayValue(value)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyableRoomId({ className = "", prefix = "", roomId }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const value = displayValue(roomId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = window.setTimeout(() => setCopied(false), 1200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [copied]);
|
||||
|
||||
const copyRoomId = async () => {
|
||||
if (!roomId) {
|
||||
return;
|
||||
}
|
||||
await copyText(String(roomId));
|
||||
setCopied(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={`复制房间 ID ${value}`}
|
||||
className={[styles.copyValue, className].filter(Boolean).join(" ")}
|
||||
disabled={!roomId}
|
||||
type="button"
|
||||
onClick={copyRoomId}
|
||||
>
|
||||
<span>{prefix}{value}</span>
|
||||
<ContentCopyOutlined className={styles.copyIcon} fontSize="inherit" />
|
||||
{copied ? <span className={styles.copyState}>已复制</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const tone = status === "active" ? "running" : "stopped";
|
||||
|
||||
@ -115,6 +151,36 @@ function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function formatRegion(value) {
|
||||
return value ? `区域 ${value}` : "-";
|
||||
function roomContributionValue(room) {
|
||||
return room?.roomContribution ?? room?.heat ?? 0;
|
||||
}
|
||||
|
||||
async function copyText(value) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
} catch {
|
||||
// 本地开发和部分浏览器策略会拒绝 Clipboard API,退回同步复制路径。
|
||||
}
|
||||
}
|
||||
|
||||
const input = document.createElement("textarea");
|
||||
input.value = value;
|
||||
input.setAttribute("readonly", "");
|
||||
input.style.position = "fixed";
|
||||
input.style.opacity = "0";
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
input.remove();
|
||||
}
|
||||
|
||||
function ownerLongShortId(owner, room) {
|
||||
const longId = owner.userId || room?.ownerUserId || "";
|
||||
const shortId = owner.displayUserId || "";
|
||||
if (longId && shortId && longId !== shortId) {
|
||||
return `${longId}(${shortId})`;
|
||||
}
|
||||
return longId || shortId || "-";
|
||||
}
|
||||
|
||||
109
src/features/rooms/hooks/useRoomConfigPage.js
Normal file
109
src/features/rooms/hooks/useRoomConfigPage.js
Normal file
@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { getRoomConfig, updateRoomConfig } from "@/features/rooms/api";
|
||||
import { useRoomAbilities } from "@/features/rooms/permissions.js";
|
||||
import { roomConfigSchema } from "@/features/rooms/schema";
|
||||
|
||||
const fallbackCandidates = [10, 15, 20, 25, 30];
|
||||
const emptyData = {
|
||||
allowedSeatCounts: fallbackCandidates,
|
||||
candidateSeatCounts: fallbackCandidates,
|
||||
defaultSeatCount: 15,
|
||||
updatedAtMs: 0,
|
||||
};
|
||||
|
||||
export function useRoomConfigPage() {
|
||||
const abilities = useRoomAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [form, setForm] = useState(emptyData);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const queryFn = useCallback(() => getRoomConfig(), []);
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载房间配置失败",
|
||||
initialData: emptyData,
|
||||
queryKey: ["rooms", "config"],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
setForm({
|
||||
allowedSeatCounts: normalizeNumbers(data.allowedSeatCounts),
|
||||
candidateSeatCounts: normalizeNumbers(data.candidateSeatCounts),
|
||||
defaultSeatCount: Number(data.defaultSeatCount || 15),
|
||||
updatedAtMs: data.updatedAtMs || 0,
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const allowedSet = useMemo(() => new Set(form.allowedSeatCounts), [form.allowedSeatCounts]);
|
||||
|
||||
const toggleSeatCount = (seatCount) => {
|
||||
setForm((current) => {
|
||||
const currentAllowed = new Set(current.allowedSeatCounts);
|
||||
if (currentAllowed.has(seatCount)) {
|
||||
currentAllowed.delete(seatCount);
|
||||
} else {
|
||||
currentAllowed.add(seatCount);
|
||||
}
|
||||
const allowedSeatCounts = [...currentAllowed].sort((left, right) => left - right);
|
||||
const defaultSeatCount = allowedSeatCounts.includes(current.defaultSeatCount)
|
||||
? current.defaultSeatCount
|
||||
: (allowedSeatCounts[0] ?? current.defaultSeatCount);
|
||||
return { ...current, allowedSeatCounts, defaultSeatCount };
|
||||
});
|
||||
};
|
||||
|
||||
const changeDefaultSeatCount = (value) => {
|
||||
setForm((current) => ({ ...current, defaultSeatCount: Number(value) }));
|
||||
};
|
||||
|
||||
const submitConfig = async (event) => {
|
||||
event.preventDefault();
|
||||
setLoadingAction("save");
|
||||
try {
|
||||
const payload = parseForm(roomConfigSchema, {
|
||||
allowedSeatCounts: form.allowedSeatCounts,
|
||||
defaultSeatCount: form.defaultSeatCount,
|
||||
});
|
||||
const saved = await updateRoomConfig(payload);
|
||||
setForm({
|
||||
allowedSeatCounts: normalizeNumbers(saved.allowedSeatCounts),
|
||||
candidateSeatCounts: normalizeNumbers(saved.candidateSeatCounts),
|
||||
defaultSeatCount: Number(saved.defaultSeatCount || 15),
|
||||
updatedAtMs: saved.updatedAtMs || 0,
|
||||
});
|
||||
await reload();
|
||||
showToast("房间配置已保存", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
allowedSet,
|
||||
changeDefaultSeatCount,
|
||||
data,
|
||||
error,
|
||||
form,
|
||||
loading,
|
||||
loadingAction,
|
||||
reload,
|
||||
submitConfig,
|
||||
toggleSeatCount,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNumbers(values) {
|
||||
return Array.isArray(values) ? values.map((value) => Number(value)).filter(Number.isFinite) : [];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user