Compare commits
No commits in common. "995bb6903b846d900bb2689e46f0c79a9b6e7100" and "dba61e808af9c54f13d93d26e73b251345ff3b8a" have entirely different histories.
995bb6903b
...
dba61e808a
@ -1,7 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.git
|
|
||||||
.DS_Store
|
|
||||||
coverage
|
|
||||||
run
|
|
||||||
*.log
|
|
||||||
@ -89,7 +89,6 @@ import { Refresh } from "@mui/icons-material";
|
|||||||
- 表格空态只显示 `当前无数据`,创建入口放在页面工具栏,不放在空态里。
|
- 表格空态只显示 `当前无数据`,创建入口放在页面工具栏,不放在空态里。
|
||||||
- 表格里的启用/禁用状态优先使用行内 Switch 快捷操作,不使用单独的启用/禁用图标按钮。
|
- 表格里的启用/禁用状态优先使用行内 Switch 快捷操作,不使用单独的启用/禁用图标按钮。
|
||||||
- 表格状态筛选使用 Select 下拉框,默认值为 `全部状态`,不使用状态 chip 组。
|
- 表格状态筛选使用 Select 下拉框,默认值为 `全部状态`,不使用状态 chip 组。
|
||||||
- 后台列表页的时间区间筛选必须使用统一的单入口时间区间筛选框(`src/shared/ui/TimeRangeFilter.jsx`);不要在工具栏里并排放两个开始/结束时间输入框,也不要使用浏览器原生日期/时间输入样式。时间区间弹层内使用快捷项、日历和时分下拉点选,不使用手写时间文本框。
|
|
||||||
- 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。
|
- 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。
|
||||||
- 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。
|
- 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。
|
||||||
- 图片上传表单必须提供统一预览;`webp` 使用浏览器原生动效预览,`svga` 使用 SVGA 播放器预览,`pag` 使用 libpag + wasm 预览。
|
- 图片上传表单必须提供统一预览;`webp` 使用浏览器原生动效预览,`svga` 使用 SVGA 播放器预览,`pag` 使用 libpag + wasm 预览。
|
||||||
|
|||||||
22
Dockerfile
22
Dockerfile
@ -1,22 +0,0 @@
|
|||||||
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
|
|
||||||
@ -2121,6 +2121,72 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/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": {
|
"/permissions": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listPermissions",
|
"operationId": "listPermissions",
|
||||||
@ -2969,6 +3035,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"NotificationListResponse": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiResponseNotificationList"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NotificationResponse": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiResponseNotification"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"PermissionListResponse": {
|
"PermissionListResponse": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"content": {
|
"content": {
|
||||||
@ -3317,6 +3403,36 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"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": {
|
"ApiResponsePermission": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
@ -3653,6 +3769,28 @@
|
|||||||
],
|
],
|
||||||
"additionalProperties": true
|
"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": {
|
"Permission": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
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,6 +4,7 @@ import LockReset from "@mui/icons-material/LockReset";
|
|||||||
import Logout from "@mui/icons-material/Logout";
|
import Logout from "@mui/icons-material/Logout";
|
||||||
import Menu from "@mui/icons-material/Menu";
|
import Menu from "@mui/icons-material/Menu";
|
||||||
import MenuOpen from "@mui/icons-material/MenuOpen";
|
import MenuOpen from "@mui/icons-material/MenuOpen";
|
||||||
|
import NotificationsNoneOutlined from "@mui/icons-material/NotificationsNoneOutlined";
|
||||||
import Person from "@mui/icons-material/Person";
|
import Person from "@mui/icons-material/Person";
|
||||||
import Refresh from "@mui/icons-material/Refresh";
|
import Refresh from "@mui/icons-material/Refresh";
|
||||||
import Search from "@mui/icons-material/Search";
|
import Search from "@mui/icons-material/Search";
|
||||||
@ -14,8 +15,11 @@ import { lazy, Suspense, useEffect, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
|
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
|
||||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
|
import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
|
||||||
import { changePassword } from "@/features/auth/api";
|
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 { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
@ -32,12 +36,20 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
|
|||||||
const [passwordError, setPasswordError] = useState("");
|
const [passwordError, setPasswordError] = useState("");
|
||||||
const [changingPassword, setChangingPassword] = useState(false);
|
const [changingPassword, setChangingPassword] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { logout, user } = useAuth();
|
const { can, logout, user } = useAuth();
|
||||||
const appScope = useAppScope();
|
const appScope = useAppScope();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { setTimeZone, timeZone, timeZoneOptions } = useTimeZone();
|
const { setTimeZone, timeZone, timeZoneOptions } = useTimeZone();
|
||||||
const ToggleIcon = isSidebarCollapsed ? MenuOpen : Menu;
|
const ToggleIcon = isSidebarCollapsed ? MenuOpen : Menu;
|
||||||
const userMenuOpen = Boolean(userMenuAnchor);
|
const userMenuOpen = Boolean(userMenuAnchor);
|
||||||
|
const canViewNotifications = can(PERMISSIONS.notificationView);
|
||||||
|
const { data: notificationState } = useAdminQuery(() => listNotifications(), {
|
||||||
|
enabled: canViewNotifications,
|
||||||
|
initialData: { items: [], unread: 0 },
|
||||||
|
queryKey: ["notifications", "unread"],
|
||||||
|
refetchInterval: 30000
|
||||||
|
});
|
||||||
|
const unread = notificationState?.unread || 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchOpen) {
|
if (searchOpen) {
|
||||||
@ -126,6 +138,10 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openNotifications = () => {
|
||||||
|
navigate("/notifications");
|
||||||
|
};
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
onRefresh();
|
onRefresh();
|
||||||
showToast("已刷新当前页面", "success");
|
showToast("已刷新当前页面", "success");
|
||||||
@ -191,6 +207,11 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
|
|||||||
<IconButton label="刷新" onClick={refresh}>
|
<IconButton label="刷新" onClick={refresh}>
|
||||||
<Refresh fontSize="small" />
|
<Refresh fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
{canViewNotifications ? (
|
||||||
|
<IconButton label="通知" notice={unread ? String(unread) : undefined} onClick={openNotifications}>
|
||||||
|
<NotificationsNoneOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
<button className="user-pill" type="button" onClick={(event) => setUserMenuAnchor(event.currentTarget)}>
|
<button className="user-pill" type="button" onClick={(event) => setUserMenuAnchor(event.currentTarget)}>
|
||||||
<Person fontSize="small" />
|
<Person fontSize="small" />
|
||||||
<span>{user?.name || user?.account || "admin"}</span>
|
<span>{user?.name || user?.account || "admin"}</span>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined";
|
import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined";
|
||||||
import BedroomParentOutlined from "@mui/icons-material/BedroomParentOutlined";
|
import BedroomParentOutlined from "@mui/icons-material/BedroomParentOutlined";
|
||||||
import BlockOutlined from "@mui/icons-material/BlockOutlined";
|
|
||||||
import CampaignOutlined from "@mui/icons-material/CampaignOutlined";
|
import CampaignOutlined from "@mui/icons-material/CampaignOutlined";
|
||||||
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||||
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
|
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
|
||||||
@ -14,13 +13,13 @@ import LoginOutlined from "@mui/icons-material/LoginOutlined";
|
|||||||
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
|
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
|
||||||
import MapOutlined from "@mui/icons-material/MapOutlined";
|
import MapOutlined from "@mui/icons-material/MapOutlined";
|
||||||
import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
|
import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
|
||||||
|
import NotificationsNoneOutlined from "@mui/icons-material/NotificationsNoneOutlined";
|
||||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||||
import PushPinOutlined from "@mui/icons-material/PushPinOutlined";
|
import PushPinOutlined from "@mui/icons-material/PushPinOutlined";
|
||||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||||
import SendOutlined from "@mui/icons-material/SendOutlined";
|
import SendOutlined from "@mui/icons-material/SendOutlined";
|
||||||
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
|
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
|
||||||
import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicationsOutlined";
|
import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicationsOutlined";
|
||||||
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
|
|
||||||
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
|
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
|
||||||
import WalletOutlined from "@mui/icons-material/WalletOutlined";
|
import WalletOutlined from "@mui/icons-material/WalletOutlined";
|
||||||
import { getRouteByMenuCode } from "@/app/router/routeConfig";
|
import { getRouteByMenuCode } from "@/app/router/routeConfig";
|
||||||
@ -33,7 +32,6 @@ const menuLabelOverrides = {
|
|||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
apartment: ApartmentOutlined,
|
apartment: ApartmentOutlined,
|
||||||
block: BlockOutlined,
|
|
||||||
campaign: CampaignOutlined,
|
campaign: CampaignOutlined,
|
||||||
category: CategoryOutlined,
|
category: CategoryOutlined,
|
||||||
dashboard: DashboardOutlined,
|
dashboard: DashboardOutlined,
|
||||||
@ -46,7 +44,7 @@ const iconMap = {
|
|||||||
login: LoginOutlined,
|
login: LoginOutlined,
|
||||||
map: MapOutlined,
|
map: MapOutlined,
|
||||||
menu: MenuOpenOutlined,
|
menu: MenuOpenOutlined,
|
||||||
operations: ReceiptLongOutlined,
|
notifications: NotificationsNoneOutlined,
|
||||||
public: PublicOutlined,
|
public: PublicOutlined,
|
||||||
push_pin: PushPinOutlined,
|
push_pin: PushPinOutlined,
|
||||||
receipt: ReceiptLongOutlined,
|
receipt: ReceiptLongOutlined,
|
||||||
@ -54,7 +52,6 @@ const iconMap = {
|
|||||||
send: SendOutlined,
|
send: SendOutlined,
|
||||||
settings: SettingsApplicationsOutlined,
|
settings: SettingsApplicationsOutlined,
|
||||||
shield: ShieldOutlined,
|
shield: ShieldOutlined,
|
||||||
sports_esports: SportsEsportsOutlined,
|
|
||||||
task: TaskAltOutlined,
|
task: TaskAltOutlined,
|
||||||
users: ManageAccountsOutlined,
|
users: ManageAccountsOutlined,
|
||||||
wallet: WalletOutlined,
|
wallet: WalletOutlined,
|
||||||
@ -82,7 +79,6 @@ export const fallbackNavigation = [
|
|||||||
label: "用户管理",
|
label: "用户管理",
|
||||||
children: [
|
children: [
|
||||||
routeNavItem("app-user-list", { icon: ManageAccountsOutlined }),
|
routeNavItem("app-user-list", { icon: ManageAccountsOutlined }),
|
||||||
routeNavItem("app-user-bans", { icon: BlockOutlined }),
|
|
||||||
routeNavItem("app-user-login-logs", { icon: LoginOutlined }),
|
routeNavItem("app-user-login-logs", { icon: LoginOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -91,11 +87,7 @@ export const fallbackNavigation = [
|
|||||||
icon: BedroomParentOutlined,
|
icon: BedroomParentOutlined,
|
||||||
id: "rooms",
|
id: "rooms",
|
||||||
label: "房间管理",
|
label: "房间管理",
|
||||||
children: [
|
children: [routeNavItem("room-list", { icon: BedroomParentOutlined }), routeNavItem("room-pins", { icon: PushPinOutlined })],
|
||||||
routeNavItem("room-list", { icon: BedroomParentOutlined }),
|
|
||||||
routeNavItem("room-pins", { icon: PushPinOutlined }),
|
|
||||||
routeNavItem("room-config", { icon: SettingsApplicationsOutlined }),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "app-config",
|
code: "app-config",
|
||||||
@ -105,8 +97,6 @@ export const fallbackNavigation = [
|
|||||||
children: [
|
children: [
|
||||||
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
|
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
|
||||||
routeNavItem("app-config-banners", { icon: ImageOutlined }),
|
routeNavItem("app-config-banners", { icon: ImageOutlined }),
|
||||||
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
|
|
||||||
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -121,22 +111,12 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("resource-grant-list", { icon: SendOutlined }),
|
routeNavItem("resource-grant-list", { icon: SendOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
code: "operations",
|
|
||||||
icon: ReceiptLongOutlined,
|
|
||||||
id: "operations",
|
|
||||||
label: "运营管理",
|
|
||||||
children: [routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined })],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
code: "activities",
|
code: "activities",
|
||||||
icon: CampaignOutlined,
|
icon: CampaignOutlined,
|
||||||
id: "activities",
|
id: "activities",
|
||||||
label: "活动管理",
|
label: "活动管理",
|
||||||
children: [
|
children: [routeNavItem("daily-task-list", { icon: TaskAltOutlined })],
|
||||||
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
|
|
||||||
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "payment",
|
code: "payment",
|
||||||
@ -145,13 +125,6 @@ export const fallbackNavigation = [
|
|||||||
label: "支付管理",
|
label: "支付管理",
|
||||||
children: [routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })],
|
children: [routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
code: "games",
|
|
||||||
icon: SportsEsportsOutlined,
|
|
||||||
id: "games",
|
|
||||||
label: "游戏管理",
|
|
||||||
children: [routeNavItem("game-list", { icon: SportsEsportsOutlined })],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
code: "geo",
|
code: "geo",
|
||||||
icon: MapOutlined,
|
icon: MapOutlined,
|
||||||
@ -168,7 +141,6 @@ export const fallbackNavigation = [
|
|||||||
id: "host-org",
|
id: "host-org",
|
||||||
label: "团队管理",
|
label: "团队管理",
|
||||||
children: [
|
children: [
|
||||||
routeNavItem("host-org-managers", { icon: ManageAccountsOutlined }),
|
|
||||||
routeNavItem("host-org-agencies", { icon: ApartmentOutlined }),
|
routeNavItem("host-org-agencies", { icon: ApartmentOutlined }),
|
||||||
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
|
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
|
||||||
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
|
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
|
||||||
@ -176,10 +148,11 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
|
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
routeNavItem("notifications", { icon: NotificationsNoneOutlined }),
|
||||||
];
|
];
|
||||||
|
|
||||||
export function mapBackendMenus(menus = []) {
|
export function mapBackendMenus(menus = []) {
|
||||||
return relocateBackendMenus(menus)
|
return relocateLogMenusToSystem(menus)
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (deprecatedMenuCodes.has(item.code)) {
|
if (deprecatedMenuCodes.has(item.code)) {
|
||||||
return null;
|
return null;
|
||||||
@ -201,38 +174,6 @@ export function mapBackendMenus(menus = []) {
|
|||||||
.filter(Boolean);
|
.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 = []) {
|
function relocateLogMenusToSystem(menus = []) {
|
||||||
const systemMenu = menus.find((item) => item.code === "system");
|
const systemMenu = menus.find((item) => item.code === "system");
|
||||||
const logsMenu = menus.find((item) => item.code === "logs");
|
const logsMenu = menus.find((item) => item.code === "logs");
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { fallbackNavigation, filterNavigationByPermission, mapBackendMenus, mergeNavigationItems } from "./menu.js";
|
import { fallbackNavigation, filterNavigationByPermission, mergeNavigationItems } from "./menu.js";
|
||||||
|
|
||||||
describe("navigation menu helpers", () => {
|
describe("navigation menu helpers", () => {
|
||||||
test("keeps only fallback menu items allowed by permissions", () => {
|
test("keeps only fallback menu items allowed by permissions", () => {
|
||||||
@ -10,7 +10,6 @@ describe("navigation menu helpers", () => {
|
|||||||
|
|
||||||
expect(flattenCodes(items)).not.toContain("geo");
|
expect(flattenCodes(items)).not.toContain("geo");
|
||||||
expect(flattenCodes(items)).toContain("host-org");
|
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-agencies");
|
||||||
expect(flattenCodes(items)).toContain("host-org-hosts");
|
expect(flattenCodes(items)).toContain("host-org-hosts");
|
||||||
expect(flattenCodes(items)).not.toContain("host-org-bds");
|
expect(flattenCodes(items)).not.toContain("host-org-bds");
|
||||||
@ -52,37 +51,6 @@ describe("navigation menu helpers", () => {
|
|||||||
const merged = mergeNavigationItems(backendMenus, fallbackMenus);
|
const merged = mergeNavigationItems(backendMenus, fallbackMenus);
|
||||||
|
|
||||||
expect(flattenCodes(merged)).toContain("host-org-agencies");
|
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -31,10 +31,6 @@ export const PERMISSIONS = {
|
|||||||
paymentProductCreate: "payment-product:create",
|
paymentProductCreate: "payment-product:create",
|
||||||
paymentProductUpdate: "payment-product:update",
|
paymentProductUpdate: "payment-product:update",
|
||||||
paymentProductDelete: "payment-product:delete",
|
paymentProductDelete: "payment-product:delete",
|
||||||
gameView: "game:view",
|
|
||||||
gameCreate: "game:create",
|
|
||||||
gameUpdate: "game:update",
|
|
||||||
gameStatus: "game:status",
|
|
||||||
roleView: "role:view",
|
roleView: "role:view",
|
||||||
roleCreate: "role:create",
|
roleCreate: "role:create",
|
||||||
roleUpdate: "role:update",
|
roleUpdate: "role:update",
|
||||||
@ -55,6 +51,10 @@ export const PERMISSIONS = {
|
|||||||
menuVisible: "menu:visible",
|
menuVisible: "menu:visible",
|
||||||
logView: "log:view",
|
logView: "log:view",
|
||||||
logExport: "log:export",
|
logExport: "log:export",
|
||||||
|
notificationView: "notification:view",
|
||||||
|
notificationRead: "notification:read",
|
||||||
|
notificationReadAll: "notification:read-all",
|
||||||
|
notificationDelete: "notification:delete",
|
||||||
appUserView: "app-user:view",
|
appUserView: "app-user:view",
|
||||||
appUserUpdate: "app-user:update",
|
appUserUpdate: "app-user:update",
|
||||||
appUserStatus: "app-user:status",
|
appUserStatus: "app-user:status",
|
||||||
@ -91,6 +91,10 @@ export const PERMISSIONS = {
|
|||||||
dailyTaskStatus: "daily-task:status",
|
dailyTaskStatus: "daily-task:status",
|
||||||
registrationRewardView: "registration-reward:view",
|
registrationRewardView: "registration-reward:view",
|
||||||
registrationRewardUpdate: "registration-reward:update",
|
registrationRewardUpdate: "registration-reward:update",
|
||||||
|
gameView: "game:view",
|
||||||
|
gameCreate: "game:create",
|
||||||
|
gameUpdate: "game:update",
|
||||||
|
gameStatus: "game:status",
|
||||||
uploadCreate: "upload:create",
|
uploadCreate: "upload:create",
|
||||||
jobView: "job:view",
|
jobView: "job:view",
|
||||||
jobCancel: "job:cancel",
|
jobCancel: "job:cancel",
|
||||||
@ -110,33 +114,27 @@ export const MENU_CODES = {
|
|||||||
logs: "logs",
|
logs: "logs",
|
||||||
logsLogin: "logs-login",
|
logsLogin: "logs-login",
|
||||||
logsOperations: "logs-operations",
|
logsOperations: "logs-operations",
|
||||||
|
notifications: "notifications",
|
||||||
appUsers: "app-users",
|
appUsers: "app-users",
|
||||||
appUserList: "app-user-list",
|
appUserList: "app-user-list",
|
||||||
appUserBans: "app-user-bans",
|
|
||||||
appUserLoginLogs: "app-user-login-logs",
|
appUserLoginLogs: "app-user-login-logs",
|
||||||
rooms: "rooms",
|
rooms: "rooms",
|
||||||
roomList: "room-list",
|
roomList: "room-list",
|
||||||
roomPins: "room-pins",
|
roomPins: "room-pins",
|
||||||
roomConfig: "room-config",
|
|
||||||
appConfig: "app-config",
|
appConfig: "app-config",
|
||||||
appConfigH5: "app-config-h5",
|
appConfigH5: "app-config-h5",
|
||||||
appConfigBanners: "app-config-banners",
|
appConfigBanners: "app-config-banners",
|
||||||
appConfigVersions: "app-config-versions",
|
|
||||||
resources: "resources",
|
resources: "resources",
|
||||||
resourceList: "resource-list",
|
resourceList: "resource-list",
|
||||||
resourceGroupList: "resource-group-list",
|
resourceGroupList: "resource-group-list",
|
||||||
resourceGrantList: "resource-grant-list",
|
resourceGrantList: "resource-grant-list",
|
||||||
giftList: "gift-list",
|
giftList: "gift-list",
|
||||||
operations: "operations",
|
|
||||||
operationCoinLedger: "operation-coin-ledger",
|
|
||||||
activities: "activities",
|
activities: "activities",
|
||||||
dailyTaskList: "daily-task-list",
|
dailyTaskList: "daily-task-list",
|
||||||
registrationReward: "registration-reward",
|
|
||||||
geo: "geo",
|
geo: "geo",
|
||||||
hostOrg: "host-org",
|
hostOrg: "host-org",
|
||||||
hostOrgCountries: "host-org-countries",
|
hostOrgCountries: "host-org-countries",
|
||||||
hostOrgRegions: "host-org-regions",
|
hostOrgRegions: "host-org-regions",
|
||||||
hostOrgManagers: "host-org-managers",
|
|
||||||
hostOrgAgencies: "host-org-agencies",
|
hostOrgAgencies: "host-org-agencies",
|
||||||
hostOrgBdLeaders: "host-org-bd-leaders",
|
hostOrgBdLeaders: "host-org-bd-leaders",
|
||||||
hostOrgBds: "host-org-bds",
|
hostOrgBds: "host-org-bds",
|
||||||
@ -144,9 +142,6 @@ export const MENU_CODES = {
|
|||||||
hostOrgCoinSellers: "host-org-coin-sellers",
|
hostOrgCoinSellers: "host-org-coin-sellers",
|
||||||
payment: "payment",
|
payment: "payment",
|
||||||
paymentBillList: "payment-bill-list",
|
paymentBillList: "payment-bill-list",
|
||||||
paymentRechargeProducts: "payment-recharge-products",
|
|
||||||
games: "games",
|
|
||||||
gameList: "game-list",
|
|
||||||
jobs: "jobs",
|
jobs: "jobs",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@ -3,13 +3,11 @@ import { appConfigRoutes } from "@/features/app-config/routes.js";
|
|||||||
import { appUserRoutes } from "@/features/app-users/routes.js";
|
import { appUserRoutes } from "@/features/app-users/routes.js";
|
||||||
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
||||||
import { dailyTaskRoutes } from "@/features/daily-tasks/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 { hostOrgRoutes } from "@/features/host-org/routes.js";
|
||||||
import { logsRoutes } from "@/features/logs/routes.js";
|
import { logsRoutes } from "@/features/logs/routes.js";
|
||||||
import { menusRoutes } from "@/features/menus/routes.js";
|
import { menusRoutes } from "@/features/menus/routes.js";
|
||||||
import { operationsRoutes } from "@/features/operations/routes.js";
|
import { notificationsRoutes } from "@/features/notifications/routes.js";
|
||||||
import { paymentRoutes } from "@/features/payment/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 { resourceRoutes } from "@/features/resources/routes.js";
|
||||||
import { rolesRoutes } from "@/features/roles/routes.js";
|
import { rolesRoutes } from "@/features/roles/routes.js";
|
||||||
import { roomRoutes } from "@/features/rooms/routes.js";
|
import { roomRoutes } from "@/features/rooms/routes.js";
|
||||||
@ -25,16 +23,14 @@ export const adminRoutes: AdminRoute[] = [
|
|||||||
...roomRoutes,
|
...roomRoutes,
|
||||||
...appConfigRoutes,
|
...appConfigRoutes,
|
||||||
...dailyTaskRoutes,
|
...dailyTaskRoutes,
|
||||||
...registrationRewardRoutes,
|
|
||||||
...resourceRoutes,
|
...resourceRoutes,
|
||||||
...operationsRoutes,
|
|
||||||
...paymentRoutes,
|
...paymentRoutes,
|
||||||
...gameRoutes,
|
|
||||||
...usersRoutes,
|
...usersRoutes,
|
||||||
...hostOrgRoutes,
|
...hostOrgRoutes,
|
||||||
...rolesRoutes,
|
...rolesRoutes,
|
||||||
...menusRoutes,
|
...menusRoutes,
|
||||||
...logsRoutes,
|
...logsRoutes,
|
||||||
|
...notificationsRoutes,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const defaultAdminPath = "/overview";
|
export const defaultAdminPath = "/overview";
|
||||||
|
|||||||
@ -1,16 +1,6 @@
|
|||||||
import { apiRequest } from "@/shared/api/request";
|
import { apiRequest } from "@/shared/api/request";
|
||||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
import type {
|
import type { ApiList, AppBannerDto, AppBannerPayload, EntityId, H5LinkConfigDto, H5LinkConfigUpdatePayload, PageQuery } from "@/shared/api/types";
|
||||||
ApiList,
|
|
||||||
AppBannerDto,
|
|
||||||
AppBannerPayload,
|
|
||||||
AppVersionDto,
|
|
||||||
AppVersionPayload,
|
|
||||||
EntityId,
|
|
||||||
H5LinkConfigDto,
|
|
||||||
H5LinkConfigUpdatePayload,
|
|
||||||
PageQuery,
|
|
||||||
} from "@/shared/api/types";
|
|
||||||
|
|
||||||
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
|
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listH5Links;
|
const endpoint = API_ENDPOINTS.listH5Links;
|
||||||
@ -57,37 +47,3 @@ export function deleteBanner(bannerId: EntityId): Promise<{ deleted: boolean }>
|
|||||||
method: endpoint.method
|
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,7 +23,3 @@
|
|||||||
.bannerCover {
|
.bannerCover {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formWideField {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,167 +0,0 @@
|
|||||||
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 || "-";
|
|
||||||
}
|
|
||||||
@ -1,242 +0,0 @@
|
|||||||
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,14 +8,10 @@ import { useMemo } from "react";
|
|||||||
import { useBannerConfigPage } from "@/features/app-config/hooks/useBannerConfigPage.js";
|
import { useBannerConfigPage } from "@/features/app-config/hooks/useBannerConfigPage.js";
|
||||||
import styles from "@/features/app-config/app-config.module.css";
|
import styles from "@/features/app-config/app-config.module.css";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import {
|
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
AdminFormDialog,
|
|
||||||
AdminFormFieldGrid,
|
|
||||||
AdminFormSection,
|
|
||||||
AdminFormSwitchField,
|
|
||||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
|
AdminFilterResetButton,
|
||||||
AdminListBody,
|
AdminListBody,
|
||||||
AdminListPage,
|
AdminListPage,
|
||||||
AdminListToolbar,
|
AdminListToolbar,
|
||||||
@ -60,6 +56,12 @@ export function BannerConfigPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null
|
) : 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}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
@ -73,7 +75,7 @@ export function BannerConfigPage() {
|
|||||||
<AdminFormDialog
|
<AdminFormDialog
|
||||||
loading={page.loadingAction === "create" || page.loadingAction === "edit"}
|
loading={page.loadingAction === "create" || page.loadingAction === "edit"}
|
||||||
open={Boolean(page.activeAction)}
|
open={Boolean(page.activeAction)}
|
||||||
size="large"
|
size="narrow"
|
||||||
submitDisabled={
|
submitDisabled={
|
||||||
!page.abilities.canUpdate || page.loadingAction === "create" || page.loadingAction === "edit"
|
!page.abilities.canUpdate || page.loadingAction === "create" || page.loadingAction === "edit"
|
||||||
}
|
}
|
||||||
@ -81,95 +83,84 @@ export function BannerConfigPage() {
|
|||||||
onClose={page.closeDialog}
|
onClose={page.closeDialog}
|
||||||
onSubmit={page.submitBanner}
|
onSubmit={page.submitBanner}
|
||||||
>
|
>
|
||||||
<AdminFormSection title="素材">
|
<UploadField
|
||||||
<UploadField
|
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
|
||||||
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
|
label="封面图"
|
||||||
label="封面图"
|
value={page.form.coverUrl}
|
||||||
value={page.form.coverUrl}
|
onChange={(coverUrl) => page.setForm({ coverUrl })}
|
||||||
onChange={(coverUrl) => page.setForm({ coverUrl })}
|
/>
|
||||||
/>
|
<TextField
|
||||||
</AdminFormSection>
|
disabled={!page.abilities.canUpdate}
|
||||||
<AdminFormSection title="跳转信息">
|
label="类型"
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
required
|
||||||
<TextField
|
select
|
||||||
disabled={!page.abilities.canUpdate}
|
value={page.form.bannerType}
|
||||||
label="类型"
|
onChange={(event) => page.setForm({ bannerType: event.target.value })}
|
||||||
required
|
>
|
||||||
select
|
<MenuItem value="h5">H5</MenuItem>
|
||||||
value={page.form.bannerType}
|
<MenuItem value="app">APP</MenuItem>
|
||||||
onChange={(event) => page.setForm({ bannerType: event.target.value })}
|
</TextField>
|
||||||
>
|
<TextField
|
||||||
<MenuItem value="h5">H5</MenuItem>
|
disabled={!page.abilities.canUpdate}
|
||||||
<MenuItem value="app">APP</MenuItem>
|
label={page.form.bannerType === "h5" ? "参数(H5链接)" : "参数"}
|
||||||
</TextField>
|
required={page.form.bannerType === "h5"}
|
||||||
<TextField
|
value={page.form.param}
|
||||||
disabled={!page.abilities.canUpdate}
|
onChange={(event) => page.setForm({ param: event.target.value })}
|
||||||
label="平台"
|
/>
|
||||||
required
|
<TextField
|
||||||
select
|
disabled={!page.abilities.canUpdate}
|
||||||
value={page.form.platform}
|
label="状态"
|
||||||
onChange={(event) => page.setForm({ platform: event.target.value })}
|
required
|
||||||
>
|
select
|
||||||
<MenuItem value="android">安卓</MenuItem>
|
value={page.form.status}
|
||||||
<MenuItem value="ios">iOS</MenuItem>
|
onChange={(event) => page.setForm({ status: event.target.value })}
|
||||||
</TextField>
|
>
|
||||||
<TextField
|
<MenuItem value="active">启用</MenuItem>
|
||||||
className={styles.formWideField}
|
<MenuItem value="disabled">关闭</MenuItem>
|
||||||
disabled={!page.abilities.canUpdate}
|
</TextField>
|
||||||
label={page.form.bannerType === "h5" ? "参数(H5链接)" : "参数"}
|
<TextField
|
||||||
required={page.form.bannerType === "h5"}
|
disabled={!page.abilities.canUpdate}
|
||||||
value={page.form.param}
|
label="平台"
|
||||||
onChange={(event) => page.setForm({ param: event.target.value })}
|
required
|
||||||
/>
|
select
|
||||||
</AdminFormFieldGrid>
|
value={page.form.platform}
|
||||||
</AdminFormSection>
|
onChange={(event) => page.setForm({ platform: event.target.value })}
|
||||||
<AdminFormSection title="投放设置">
|
>
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
<MenuItem value="android">安卓</MenuItem>
|
||||||
<RegionSelect
|
<MenuItem value="ios">iOS</MenuItem>
|
||||||
disabled={!page.abilities.canUpdate}
|
</TextField>
|
||||||
emptyLabel="全部区域"
|
<TextField
|
||||||
loading={page.loadingRegions}
|
disabled={!page.abilities.canUpdate}
|
||||||
options={page.regionOptions}
|
inputProps={{ step: 1 }}
|
||||||
value={page.form.regionId}
|
label="排序"
|
||||||
onChange={(regionId) => page.setForm({ regionId })}
|
type="number"
|
||||||
/>
|
value={page.form.sortOrder}
|
||||||
<CountrySelect
|
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
|
||||||
disabled={!page.abilities.canUpdate || page.loadingCountries}
|
/>
|
||||||
emptyLabel="全部国家"
|
<RegionSelect
|
||||||
label="国家"
|
disabled={!page.abilities.canUpdate}
|
||||||
options={page.countryOptions}
|
emptyLabel="全部区域"
|
||||||
value={page.form.countryCode}
|
loading={page.loadingRegions}
|
||||||
onChange={(countryCode) => page.setForm({ countryCode })}
|
options={page.regionOptions}
|
||||||
/>
|
value={page.form.regionId}
|
||||||
<TextField
|
onChange={(regionId) => page.setForm({ regionId })}
|
||||||
disabled={!page.abilities.canUpdate}
|
/>
|
||||||
inputProps={{ step: 1 }}
|
<CountrySelect
|
||||||
label="排序"
|
disabled={!page.abilities.canUpdate || page.loadingCountries}
|
||||||
type="number"
|
emptyLabel="全部国家"
|
||||||
value={page.form.sortOrder}
|
label="国家"
|
||||||
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
|
options={page.countryOptions}
|
||||||
/>
|
value={page.form.countryCode}
|
||||||
<AdminFormSwitchField
|
onChange={(countryCode) => page.setForm({ countryCode })}
|
||||||
checked={page.form.status === "active"}
|
/>
|
||||||
checkedLabel="启用"
|
<TextField
|
||||||
disabled={!page.abilities.canUpdate}
|
disabled={!page.abilities.canUpdate}
|
||||||
label="BANNER 状态"
|
label="描述"
|
||||||
switchProps={{ inputProps: { "aria-label": "BANNER 启用状态" } }}
|
multiline
|
||||||
uncheckedLabel="关闭"
|
minRows={2}
|
||||||
onChange={(checked) => page.setForm({ status: checked ? "active" : "disabled" })}
|
value={page.form.description}
|
||||||
/>
|
onChange={(event) => page.setForm({ description: event.target.value })}
|
||||||
</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>
|
</AdminFormDialog>
|
||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import EditOutlined from "@mui/icons-material/EditOutlined";
|
|||||||
import LinkOutlined from "@mui/icons-material/LinkOutlined";
|
import LinkOutlined from "@mui/icons-material/LinkOutlined";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import {
|
import {
|
||||||
@ -74,15 +74,13 @@ export function H5ConfigPage() {
|
|||||||
onClose={page.closeEdit}
|
onClose={page.closeEdit}
|
||||||
onSubmit={page.submitEdit}
|
onSubmit={page.submitEdit}
|
||||||
>
|
>
|
||||||
<AdminFormSection title="链接信息">
|
<TextField
|
||||||
<TextField
|
autoFocus
|
||||||
autoFocus
|
disabled={!page.abilities.canUpdate}
|
||||||
disabled={!page.abilities.canUpdate}
|
label={page.editingItem?.label || "H5链接"}
|
||||||
label={page.editingItem?.label || "H5链接"}
|
value={page.form.url}
|
||||||
value={page.form.url}
|
onChange={(event) => page.setForm({ url: event.target.value })}
|
||||||
onChange={(event) => page.setForm({ url: event.target.value })}
|
/>
|
||||||
/>
|
|
||||||
</AdminFormSection>
|
|
||||||
</AdminFormDialog>
|
</AdminFormDialog>
|
||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,12 +5,8 @@ export function useAppConfigAbilities() {
|
|||||||
const { can } = useAuth();
|
const { can } = useAuth();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canCreateVersion: can(PERMISSIONS.appVersionCreate),
|
|
||||||
canDeleteVersion: can(PERMISSIONS.appVersionDelete),
|
|
||||||
canUpdate: can(PERMISSIONS.appConfigUpdate),
|
canUpdate: can(PERMISSIONS.appConfigUpdate),
|
||||||
canUpdateVersion: can(PERMISSIONS.appVersionUpdate),
|
|
||||||
canUpload: can(PERMISSIONS.uploadCreate),
|
canUpload: can(PERMISSIONS.uploadCreate),
|
||||||
canView: can(PERMISSIONS.appConfigView),
|
canView: can(PERMISSIONS.appConfigView)
|
||||||
canViewVersion: can(PERMISSIONS.appVersionView)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,22 +16,5 @@ export const appConfigRoutes = [
|
|||||||
pageKey: "app-config-banners",
|
pageKey: "app-config-banners",
|
||||||
path: "/app-config/banners",
|
path: "/app-config/banners",
|
||||||
permission: PERMISSIONS.appConfigView
|
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,15 +38,3 @@ export const appBannerSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type AppBannerForm = z.infer<typeof appBannerSchema>;
|
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,7 +2,6 @@ import { apiRequest } from "@/shared/api/request";
|
|||||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
import type {
|
import type {
|
||||||
ApiPage,
|
ApiPage,
|
||||||
AppUserBanRecordDto,
|
|
||||||
AppUserLoginLogDto,
|
AppUserLoginLogDto,
|
||||||
AppUserDto,
|
AppUserDto,
|
||||||
AppUserPasswordPayload,
|
AppUserPasswordPayload,
|
||||||
@ -27,14 +26,6 @@ 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> {
|
export function updateAppUser(userId: EntityId, payload: AppUserUpdatePayload): Promise<AppUserDto> {
|
||||||
const endpoint = API_ENDPOINTS.appUpdateUser;
|
const endpoint = API_ENDPOINTS.appUpdateUser;
|
||||||
return apiRequest<AppUserDto, AppUserUpdatePayload>(apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }), {
|
return apiRequest<AppUserDto, AppUserUpdatePayload>(apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }), {
|
||||||
|
|||||||
@ -1,194 +0,0 @@
|
|||||||
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,10 +7,12 @@ import Autocomplete from "@mui/material/Autocomplete";
|
|||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
@ -89,24 +91,28 @@ export function AppUserListPage() {
|
|||||||
return (
|
return (
|
||||||
<section className={styles.root}>
|
<section className={styles.root}>
|
||||||
<div className={styles.contentPanel}>
|
<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}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<div className={styles.listBlock}>
|
<div className={styles.listBlock}>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1220px"
|
minWidth="1220px"
|
||||||
pagination={
|
|
||||||
total > 0
|
|
||||||
? {
|
|
||||||
page: page.page,
|
|
||||||
pageSize: page.data.pageSize || 10,
|
|
||||||
total,
|
|
||||||
onPageChange: page.setPage,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rowKey={(user) => user.userId}
|
rowKey={(user) => user.userId}
|
||||||
/>
|
/>
|
||||||
|
{total > 0 ? (
|
||||||
|
<PaginationBar
|
||||||
|
page={page.page}
|
||||||
|
pageSize={page.data.pageSize || 10}
|
||||||
|
total={total}
|
||||||
|
onPageChange={page.setPage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</DataState>
|
</DataState>
|
||||||
</div>
|
</div>
|
||||||
@ -115,7 +121,6 @@ export function AppUserListPage() {
|
|||||||
disabled={!page.abilities.canUpdate}
|
disabled={!page.abilities.canUpdate}
|
||||||
loading={page.loadingAction === "edit"}
|
loading={page.loadingAction === "edit"}
|
||||||
open={page.activeAction === "edit"}
|
open={page.activeAction === "edit"}
|
||||||
sectionTitle="用户资料"
|
|
||||||
title="编辑用户"
|
title="编辑用户"
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitEdit}
|
onSubmit={page.submitEdit}
|
||||||
@ -151,7 +156,6 @@ export function AppUserListPage() {
|
|||||||
disabled={!page.abilities.canUpdate}
|
disabled={!page.abilities.canUpdate}
|
||||||
loading={page.loadingAction === "country"}
|
loading={page.loadingAction === "country"}
|
||||||
open={page.activeAction === "country"}
|
open={page.activeAction === "country"}
|
||||||
sectionTitle="国家信息"
|
|
||||||
title="修改国家"
|
title="修改国家"
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitCountry}
|
onSubmit={page.submitCountry}
|
||||||
@ -173,7 +177,6 @@ export function AppUserListPage() {
|
|||||||
disabled={!page.abilities.canPassword}
|
disabled={!page.abilities.canPassword}
|
||||||
loading={page.loadingAction === "password"}
|
loading={page.loadingAction === "password"}
|
||||||
open={page.activeAction === "password"}
|
open={page.activeAction === "password"}
|
||||||
sectionTitle="密码信息"
|
|
||||||
title="设置密码"
|
title="设置密码"
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitPassword}
|
onSubmit={page.submitPassword}
|
||||||
@ -277,7 +280,7 @@ function UserStatus({ status }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionModal({ children, disabled, loading, onClose, onSubmit, open, sectionTitle = "基本信息", title }) {
|
function ActionModal({ children, disabled, loading, onClose, onSubmit, open, title }) {
|
||||||
return (
|
return (
|
||||||
<AdminFormDialog
|
<AdminFormDialog
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@ -288,7 +291,7 @@ function ActionModal({ children, disabled, loading, onClose, onSubmit, open, sec
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
<AdminFormSection title={sectionTitle}>{children}</AdminFormSection>
|
{children}
|
||||||
</AdminFormDialog>
|
</AdminFormDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import { useSearchParams } from "react-router-dom";
|
|||||||
import { toPageQuery } from "@/shared/api/query";
|
import { toPageQuery } from "@/shared/api/query";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.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 { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||||
@ -65,6 +68,11 @@ export function AppUserLoginLogsPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearUserFilter = useCallback(() => {
|
||||||
|
setSearchParams(new URLSearchParams(), { replace: true });
|
||||||
|
setPage(1);
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
const showUserHistory = useCallback(
|
const showUserHistory = useCallback(
|
||||||
(log) => {
|
(log) => {
|
||||||
if (!log.userId) {
|
if (!log.userId) {
|
||||||
@ -81,29 +89,13 @@ export function AppUserLoginLogsPage() {
|
|||||||
[searchParams, setSearchParams],
|
[searchParams, setSearchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resetIdentityFilter = useCallback(() => {
|
const resetFilters = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
|
setResult("");
|
||||||
|
setRegionId("");
|
||||||
setSearchParams(new URLSearchParams(), { replace: true });
|
setSearchParams(new URLSearchParams(), { replace: true });
|
||||||
setPage(1);
|
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(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@ -112,12 +104,9 @@ export function AppUserLoginLogsPage() {
|
|||||||
label: "用户",
|
label: "用户",
|
||||||
width: "minmax(240px, 1.4fr)",
|
width: "minmax(240px, 1.4fr)",
|
||||||
filter: createTextColumnFilter({
|
filter: createTextColumnFilter({
|
||||||
activeLabel: userFilter ? selectedUserLabel || userFilter : undefined,
|
|
||||||
activeValue: query || userFilter,
|
|
||||||
onReset: resetIdentityFilter,
|
|
||||||
placeholder: "搜索昵称、短 ID、用户 ID、IP",
|
placeholder: "搜索昵称、短 ID、用户 ID、IP",
|
||||||
value: query,
|
value: query,
|
||||||
onChange: changeIdentityFilter,
|
onChange: changeQuery,
|
||||||
}),
|
}),
|
||||||
render: (log) => <UserIdentity log={log} />,
|
render: (log) => <UserIdentity log={log} />,
|
||||||
},
|
},
|
||||||
@ -199,24 +188,31 @@ export function AppUserLoginLogsPage() {
|
|||||||
render: (log) => <span className="muted">{formatMillis(log.createdAtMs)}</span>,
|
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 (
|
return (
|
||||||
<section className={styles.root}>
|
<section className={styles.root}>
|
||||||
<div className={styles.contentPanel}>
|
<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}>
|
<DataState error={error} loading={loading} onRetry={reload}>
|
||||||
<div className={styles.listBlock}>
|
<div className={styles.listBlock}>
|
||||||
<DataTable
|
<DataTable
|
||||||
@ -226,17 +222,17 @@ export function AppUserLoginLogsPage() {
|
|||||||
minWidth="1380px"
|
minWidth="1380px"
|
||||||
getRowProps={(log) => loginLogRowProps(log, showUserHistory)}
|
getRowProps={(log) => loginLogRowProps(log, showUserHistory)}
|
||||||
rowKey={(log) => log.id}
|
rowKey={(log) => log.id}
|
||||||
pagination={
|
title="登录日志"
|
||||||
data.total > 0
|
total={data.total}
|
||||||
? {
|
|
||||||
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>
|
</div>
|
||||||
</DataState>
|
</DataState>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -17,12 +17,4 @@ export const appUserRoutes = [
|
|||||||
path: "/app/users/login-logs",
|
path: "/app/users/login-logs",
|
||||||
permission: PERMISSIONS.appUserView,
|
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,14 +1,9 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import Switch from "@mui/material/Switch";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import {
|
import { AdminFormDialog, AdminFormFieldGrid } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
AdminFormDialog,
|
|
||||||
AdminFormFieldGrid,
|
|
||||||
AdminFormSection,
|
|
||||||
AdminFormSwitchField,
|
|
||||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import {
|
import {
|
||||||
@ -20,6 +15,7 @@ import {
|
|||||||
AdminListToolbar,
|
AdminListToolbar,
|
||||||
AdminRowActions,
|
AdminRowActions,
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import {
|
import {
|
||||||
@ -179,22 +175,15 @@ export function DailyTaskListPage() {
|
|||||||
/>
|
/>
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
<DataTable
|
<DataTable columns={columns} items={items} minWidth="1340px" rowKey={(task) => task.taskId} />
|
||||||
columns={columns}
|
{total > 0 ? (
|
||||||
items={items}
|
<PaginationBar
|
||||||
minWidth="1340px"
|
page={page.page}
|
||||||
pagination={
|
pageSize={page.data.pageSize || 10}
|
||||||
total > 0
|
total={total}
|
||||||
? {
|
onPageChange={page.setPage}
|
||||||
page: page.page,
|
/>
|
||||||
pageSize: page.data.pageSize || 10,
|
) : null}
|
||||||
total,
|
|
||||||
onPageChange: page.setPage,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rowKey={(task) => task.taskId}
|
|
||||||
/>
|
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
<TaskFormDialog
|
<TaskFormDialog
|
||||||
@ -229,7 +218,7 @@ function TaskStatusSwitch({ page, task }) {
|
|||||||
!page.abilities.canStatus || task.status === "archived" || page.loadingAction === `status-${task.taskId}`;
|
!page.abilities.canStatus || task.status === "archived" || page.loadingAction === `status-${task.taskId}`;
|
||||||
return (
|
return (
|
||||||
<div className={styles.statusCell}>
|
<div className={styles.statusCell}>
|
||||||
<AdminSwitch
|
<Switch
|
||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
inputProps={{ "aria-label": checked ? "暂停任务" : "启用任务" }}
|
inputProps={{ "aria-label": checked ? "暂停任务" : "启用任务" }}
|
||||||
@ -265,118 +254,111 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
<AdminFormSection title="基础信息">
|
<AdminFormFieldGrid>
|
||||||
<AdminFormFieldGrid>
|
<TextField
|
||||||
<TextField
|
disabled={disabled}
|
||||||
disabled={disabled}
|
label="任务类型"
|
||||||
label="任务类型"
|
required
|
||||||
required
|
select
|
||||||
select
|
value={form.taskType}
|
||||||
value={form.taskType}
|
onChange={(event) => page.setForm({ ...form, taskType: event.target.value })}
|
||||||
onChange={(event) => page.setForm({ ...form, taskType: event.target.value })}
|
>
|
||||||
>
|
{taskTypeOptions.map(([value, label]) => (
|
||||||
{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]) => (
|
||||||
<MenuItem key={value} value={value}>
|
<MenuItem key={value} value={value}>
|
||||||
{label}
|
{label}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
<AdminFormSwitchField
|
<TextField
|
||||||
checked={form.status === "active"}
|
disabled={disabled}
|
||||||
checkedLabel="启用"
|
label="任务名称"
|
||||||
disabled={disabled || form.status === "archived"}
|
required
|
||||||
label="任务状态"
|
value={form.title}
|
||||||
switchProps={{ inputProps: { "aria-label": "任务启用状态" } }}
|
onChange={(event) => page.setForm({ ...form, title: event.target.value })}
|
||||||
uncheckedLabel={taskSwitchUncheckedLabel(form.status, mode)}
|
/>
|
||||||
onChange={(checked) =>
|
<TextField
|
||||||
page.setForm({ ...form, status: nextTaskStatusFromSwitch(form.status, checked, mode) })
|
disabled={disabled}
|
||||||
}
|
label="任务分类"
|
||||||
/>
|
required
|
||||||
<TextField
|
value={form.category}
|
||||||
disabled={disabled}
|
onChange={(event) => page.setForm({ ...form, category: event.target.value })}
|
||||||
label="任务名称"
|
/>
|
||||||
required
|
<TextField
|
||||||
value={form.title}
|
disabled={disabled}
|
||||||
onChange={(event) => page.setForm({ ...form, title: event.target.value })}
|
label="指标类型"
|
||||||
/>
|
required
|
||||||
<TextField
|
value={form.metricType}
|
||||||
disabled={disabled}
|
onChange={(event) => page.setForm({ ...form, metricType: event.target.value })}
|
||||||
label="任务分类"
|
/>
|
||||||
required
|
<TextField
|
||||||
value={form.category}
|
disabled={disabled}
|
||||||
onChange={(event) => page.setForm({ ...form, category: event.target.value })}
|
label="目标单位"
|
||||||
/>
|
required
|
||||||
</AdminFormFieldGrid>
|
select
|
||||||
</AdminFormSection>
|
value={form.targetUnit}
|
||||||
<AdminFormSection title="目标与奖励">
|
onChange={(event) => page.setForm({ ...form, targetUnit: event.target.value })}
|
||||||
<AdminFormFieldGrid>
|
>
|
||||||
<TextField
|
{taskUnitOptions.map(([value, label]) => (
|
||||||
disabled={disabled}
|
<MenuItem key={value} value={value}>
|
||||||
label="指标类型"
|
{label}
|
||||||
required
|
</MenuItem>
|
||||||
value={form.metricType}
|
))}
|
||||||
onChange={(event) => page.setForm({ ...form, metricType: event.target.value })}
|
</TextField>
|
||||||
/>
|
<TextField
|
||||||
<TextField
|
disabled={disabled}
|
||||||
disabled={disabled}
|
label="目标值"
|
||||||
label="目标单位"
|
required
|
||||||
required
|
type="number"
|
||||||
select
|
value={form.targetValue}
|
||||||
value={form.targetUnit}
|
onChange={(event) => page.setForm({ ...form, targetValue: event.target.value })}
|
||||||
onChange={(event) => page.setForm({ ...form, targetUnit: event.target.value })}
|
/>
|
||||||
>
|
<TextField
|
||||||
{taskUnitOptions.map(([value, label]) => (
|
disabled={disabled}
|
||||||
<MenuItem key={value} value={value}>
|
label="奖励金币"
|
||||||
{label}
|
required
|
||||||
</MenuItem>
|
type="number"
|
||||||
))}
|
value={form.rewardCoinAmount}
|
||||||
</TextField>
|
onChange={(event) => page.setForm({ ...form, rewardCoinAmount: event.target.value })}
|
||||||
<TextField
|
/>
|
||||||
disabled={disabled}
|
<TextField
|
||||||
label="目标值"
|
disabled={disabled}
|
||||||
required
|
label="排序"
|
||||||
type="number"
|
type="number"
|
||||||
value={form.targetValue}
|
value={form.sortOrder}
|
||||||
onChange={(event) => page.setForm({ ...form, targetValue: event.target.value })}
|
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="奖励金币"
|
label="有效开始时间"
|
||||||
required
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
type="number"
|
type="datetime-local"
|
||||||
value={form.rewardCoinAmount}
|
value={form.effectiveFrom}
|
||||||
onChange={(event) => page.setForm({ ...form, rewardCoinAmount: event.target.value })}
|
onChange={(event) => page.setForm({ ...form, effectiveFrom: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="排序"
|
label="有效结束时间"
|
||||||
type="number"
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
value={form.sortOrder}
|
type="datetime-local"
|
||||||
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
|
value={form.effectiveTo}
|
||||||
/>
|
onChange={(event) => page.setForm({ ...form, effectiveTo: 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
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="任务描述"
|
label="任务描述"
|
||||||
@ -385,7 +367,7 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={(event) => page.setForm({ ...form, description: event.target.value })}
|
onChange={(event) => page.setForm({ ...form, description: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</AdminFormSection>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormDialog>
|
</AdminFormDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -398,23 +380,6 @@ function taskStatusLabel(value) {
|
|||||||
return taskStatusLabels[value] || 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) {
|
function taskUnitLabel(value) {
|
||||||
return taskUnitLabels[value] || value || "";
|
return taskUnitLabels[value] || value || "";
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/features/dashboard/components/DashboardAlerts.jsx
Normal file
16
src/features/dashboard/components/DashboardAlerts.jsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export function DashboardAlerts({ alerts = [] }) {
|
||||||
|
return (
|
||||||
|
<section className="alert-strip" aria-label="实时告警">
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<div className="alert-item" key={`${alert.name}-${alert.time}`}>
|
||||||
|
<span className={`alert-dot alert-dot--${alert.type}`} />
|
||||||
|
<div>
|
||||||
|
<div className="alert-name">{alert.name}</div>
|
||||||
|
<div className="alert-desc">{alert.desc}</div>
|
||||||
|
</div>
|
||||||
|
<div className="alert-time">{alert.time}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { Card } from "@/shared/ui/Card.jsx";
|
||||||
|
import { BarChart } from "@/shared/charts/BarChart.jsx";
|
||||||
import { ChartCard } from "@/shared/charts/ChartCard.jsx";
|
import { ChartCard } from "@/shared/charts/ChartCard.jsx";
|
||||||
|
|
||||||
export function DashboardCharts({ overview }) {
|
export function DashboardCharts({ overview }) {
|
||||||
@ -5,6 +7,12 @@ export function DashboardCharts({ overview }) {
|
|||||||
<section className="overview-charts" aria-label="资源图表">
|
<section className="overview-charts" aria-label="资源图表">
|
||||||
<ChartCard title="后台用户" colorToken="--chart-blue" series={overview?.series?.users || []} />
|
<ChartCard title="后台用户" colorToken="--chart-blue" series={overview?.series?.users || []} />
|
||||||
<ChartCard title="操作日志" colorToken="--chart-green" series={overview?.series?.operations || []} />
|
<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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import DeveloperBoardOutlined from "@mui/icons-material/DeveloperBoardOutlined";
|
import DeveloperBoardOutlined from "@mui/icons-material/DeveloperBoardOutlined";
|
||||||
import DnsOutlined from "@mui/icons-material/DnsOutlined";
|
import DnsOutlined from "@mui/icons-material/DnsOutlined";
|
||||||
|
import SecurityOutlined from "@mui/icons-material/SecurityOutlined";
|
||||||
import StorageOutlined from "@mui/icons-material/StorageOutlined";
|
import StorageOutlined from "@mui/icons-material/StorageOutlined";
|
||||||
import { KpiCard } from "@/shared/ui/KpiCard.jsx";
|
import { KpiCard } from "@/shared/ui/KpiCard.jsx";
|
||||||
|
|
||||||
@ -9,6 +10,7 @@ export function DashboardStats({ overview, stats }) {
|
|||||||
<KpiCard icon={DnsOutlined} label="后台用户" value={overview?.usersTotal || 0} unit="个" sub={<span className="status-ok">启用 {stats.activeUsers}</span>} />
|
<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={DeveloperBoardOutlined} label="角色数量" value={stats.rolesTotal} unit="个" sub={`菜单 ${stats.menuTotal}`} />
|
||||||
<KpiCard icon={StorageOutlined} label="今日操作" value={stats.logsToday} unit="条" tone="info" sub="操作日志" />
|
<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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export function useDashboardPage() {
|
|||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
if (!overview) {
|
if (!overview) {
|
||||||
return { activeUsers: 0, disabledUsers: 0, lockedUsers: 0, logsToday: 0, menuTotal: 0, rolesTotal: 0, usersTotal: 0 };
|
return { activeUsers: 0, disabledUsers: 0, lockedUsers: 0, logsToday: 0, menuTotal: 0, rolesTotal: 0, unreadNotifications: 0, usersTotal: 0 };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
activeUsers: overview.activeUsers,
|
activeUsers: overview.activeUsers,
|
||||||
@ -32,6 +32,7 @@ export function useDashboardPage() {
|
|||||||
logsToday: overview.logsToday,
|
logsToday: overview.logsToday,
|
||||||
menuTotal: overview.menuTotal,
|
menuTotal: overview.menuTotal,
|
||||||
rolesTotal: overview.rolesTotal,
|
rolesTotal: overview.rolesTotal,
|
||||||
|
unreadNotifications: overview.unreadNotifications,
|
||||||
usersTotal: overview.usersTotal
|
usersTotal: overview.usersTotal
|
||||||
};
|
};
|
||||||
}, [overview]);
|
}, [overview]);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DashboardAlerts } from "@/features/dashboard/components/DashboardAlerts.jsx";
|
||||||
import { DashboardCharts } from "@/features/dashboard/components/DashboardCharts.jsx";
|
import { DashboardCharts } from "@/features/dashboard/components/DashboardCharts.jsx";
|
||||||
import { DashboardStats } from "@/features/dashboard/components/DashboardStats.jsx";
|
import { DashboardStats } from "@/features/dashboard/components/DashboardStats.jsx";
|
||||||
import { DashboardToolbar } from "@/features/dashboard/components/DashboardToolbar.jsx";
|
import { DashboardToolbar } from "@/features/dashboard/components/DashboardToolbar.jsx";
|
||||||
@ -19,6 +20,8 @@ export function OverviewPage() {
|
|||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<DashboardStats overview={page.overview} stats={page.stats} />
|
<DashboardStats overview={page.overview} stats={page.stats} />
|
||||||
<DashboardCharts overview={page.overview} />
|
<DashboardCharts overview={page.overview} />
|
||||||
|
|
||||||
|
<DashboardAlerts alerts={page.overview?.alerts || []} />
|
||||||
</DataState>
|
</DataState>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,155 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
@ -1,303 +0,0 @@
|
|||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,440 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
|
|
||||||
export function HostOrgActionModal({
|
export function HostOrgActionModal({
|
||||||
children,
|
children,
|
||||||
@ -7,8 +7,6 @@ export function HostOrgActionModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
open,
|
open,
|
||||||
sectioned = true,
|
|
||||||
sectionTitle = "表单信息",
|
|
||||||
size = "compact",
|
size = "compact",
|
||||||
submitLabel = "提交",
|
submitLabel = "提交",
|
||||||
submitVariant = "primary",
|
submitVariant = "primary",
|
||||||
@ -26,7 +24,7 @@ export function HostOrgActionModal({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
{sectioned ? <AdminFormSection title={sectionTitle}>{children}</AdminFormSection> : children}
|
{children}
|
||||||
</AdminFormDialog>
|
</AdminFormDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/features/host-org/components/HostOrgFilters.jsx
Normal file
20
src/features/host-org/components/HostOrgFilters.jsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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,32 +1,35 @@
|
|||||||
import Tooltip from "@mui/material/Tooltip";
|
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 styles from "@/features/host-org/host-org.module.css";
|
||||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
|
|
||||||
export function HostOrgToolbar({ actions = [] }) {
|
export function HostOrgToolbar({ actions = [], filters }) {
|
||||||
if (!actions.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
<div className={styles.toolbarActions}>
|
<div className={styles.toolbarLeft}>
|
||||||
{actions.map((action) => (
|
<HostOrgFilters {...filters} />
|
||||||
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -116,19 +116,6 @@ 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) => {
|
const submitJoinEnabled = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
await runAction("agency-join", "入会开关已更新", async () => {
|
await runAction("agency-join", "入会开关已更新", async () => {
|
||||||
@ -162,7 +149,6 @@ export function useHostAgenciesPage() {
|
|||||||
changeQuery,
|
changeQuery,
|
||||||
changeRegionId,
|
changeRegionId,
|
||||||
changeStatus,
|
changeStatus,
|
||||||
closeAgencyFromList,
|
|
||||||
closeForm,
|
closeForm,
|
||||||
data,
|
data,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@ -15,10 +15,17 @@ import {
|
|||||||
import { listAppUsers } from "@/features/app-users/api";
|
import { listAppUsers } from "@/features/app-users/api";
|
||||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||||
import { useBDAbilities } from "@/features/host-org/permissions.js";
|
import { useBDAbilities } from "@/features/host-org/permissions.js";
|
||||||
import { createBDSchema, createBDLeaderSchema } from "@/features/host-org/schema";
|
import { bdStatusSchema, createBDSchema, createBDLeaderSchema } from "@/features/host-org/schema";
|
||||||
|
|
||||||
const emptyBDLeaderForm = () => ({ commandId: makeCommandId("bd-leader"), reason: "", regionId: "", targetUserId: "" });
|
const emptyBDLeaderForm = () => ({ commandId: makeCommandId("bd-leader"), reason: "", regionId: "", targetUserId: "" });
|
||||||
const emptyBDForm = () => ({ commandId: makeCommandId("bd"), parentLeaderUserId: "", reason: "", 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 pageSize = 10;
|
||||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||||
const emptyLookup = () => ({ error: "", loading: false, user: null });
|
const emptyLookup = () => ({ error: "", loading: false, user: null });
|
||||||
@ -35,6 +42,7 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [bdLeaderForm, setBDLeaderForm] = useState(emptyBDLeaderForm);
|
const [bdLeaderForm, setBDLeaderForm] = useState(emptyBDLeaderForm);
|
||||||
const [bdForm, setBDForm] = useState(emptyBDForm);
|
const [bdForm, setBDForm] = useState(emptyBDForm);
|
||||||
|
const [bdStatusForm, setBDStatusForm] = useState(() => emptyBDStatusForm(profileType));
|
||||||
const [bdUserLookup, setBDUserLookup] = useState(emptyLookup);
|
const [bdUserLookup, setBDUserLookup] = useState(emptyLookup);
|
||||||
const [leaderBDs, setLeaderBDs] = useState(emptyData);
|
const [leaderBDs, setLeaderBDs] = useState(emptyData);
|
||||||
const [leaderBDsError, setLeaderBDsError] = useState("");
|
const [leaderBDsError, setLeaderBDsError] = useState("");
|
||||||
@ -186,6 +194,22 @@ 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 toggleBDLeader = async (leader, enabled) => {
|
||||||
const status = enabled ? "active" : "disabled";
|
const status = enabled ? "active" : "disabled";
|
||||||
await runAction(
|
await runAction(
|
||||||
@ -203,19 +227,6 @@ 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 removeBDLeader = async (leader) => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
confirmText: "删除",
|
confirmText: "删除",
|
||||||
@ -273,6 +284,7 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
|||||||
activeAction,
|
activeAction,
|
||||||
bdForm,
|
bdForm,
|
||||||
bdLeaderForm,
|
bdLeaderForm,
|
||||||
|
bdStatusForm,
|
||||||
bdUserLookup,
|
bdUserLookup,
|
||||||
changeParentLeaderUserId,
|
changeParentLeaderUserId,
|
||||||
changeQuery,
|
changeQuery,
|
||||||
@ -300,16 +312,18 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
|||||||
openAddLeaderBD,
|
openAddLeaderBD,
|
||||||
openBDForm: () => setActiveAction("bd"),
|
openBDForm: () => setActiveAction("bd"),
|
||||||
openBDLeaderForm: () => setActiveAction("bd-leader"),
|
openBDLeaderForm: () => setActiveAction("bd-leader"),
|
||||||
|
openBDStatusForm: () => setActiveAction("bd-status"),
|
||||||
openLeaderBDs,
|
openLeaderBDs,
|
||||||
selectedLeader,
|
selectedLeader,
|
||||||
setBDForm,
|
setBDForm,
|
||||||
setBDLeaderForm,
|
setBDLeaderForm,
|
||||||
|
setBDStatusForm,
|
||||||
setPage,
|
setPage,
|
||||||
status,
|
status,
|
||||||
submitBD,
|
submitBD,
|
||||||
submitBDLeader,
|
submitBDLeader,
|
||||||
submitLeaderBD,
|
submitLeaderBD,
|
||||||
toggleBD,
|
submitBDStatus,
|
||||||
toggleBDLeader,
|
toggleBDLeader,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,123 +0,0 @@
|
|||||||
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,7 +63,6 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
|
|||||||
@ -1,13 +1,16 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
import BlockOutlined from "@mui/icons-material/BlockOutlined";
|
import BlockOutlined from "@mui/icons-material/BlockOutlined";
|
||||||
import ToggleOnOutlined from "@mui/icons-material/ToggleOnOutlined";
|
import ToggleOnOutlined from "@mui/icons-material/ToggleOnOutlined";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import Switch from "@mui/material/Switch";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
import { agencyStatusFilters } from "@/features/host-org/constants.js";
|
import { agencyStatusFilters } from "@/features/host-org/constants.js";
|
||||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.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 { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||||
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
||||||
import { useHostAgenciesPage } from "@/features/host-org/hooks/useHostAgenciesPage.js";
|
import { useHostAgenciesPage } from "@/features/host-org/hooks/useHostAgenciesPage.js";
|
||||||
@ -42,7 +45,7 @@ const agencyColumns = [
|
|||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
label: "状态",
|
label: "状态",
|
||||||
render: (item, _index, context) => <AgencyStatusSwitch item={item} page={context?.page} />,
|
render: (item) => <HostOrgStatus value={item.status} />,
|
||||||
width: "minmax(90px, 0.7fr)",
|
width: "minmax(90px, 0.7fr)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -126,27 +129,46 @@ export function HostAgenciesPage() {
|
|||||||
<div className={styles.contentPanel}>
|
<div className={styles.contentPanel}>
|
||||||
<HostOrgToolbar
|
<HostOrgToolbar
|
||||||
actions={toolbarActions}
|
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}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<div className={styles.listBlock}>
|
<div className={styles.listBlock}>
|
||||||
<HostOrgTable
|
<HostOrgTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
context={{ page, regionOptions: page.regionOptions }}
|
context={{ regionOptions: page.regionOptions }}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1250px"
|
minWidth="1250px"
|
||||||
pagination={
|
|
||||||
total > 0
|
|
||||||
? {
|
|
||||||
page: page.page,
|
|
||||||
pageSize: page.data.pageSize || 10,
|
|
||||||
total,
|
|
||||||
onPageChange: page.setPage,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rowKey={(item) => item.agencyId}
|
rowKey={(item) => item.agencyId}
|
||||||
/>
|
/>
|
||||||
|
{total > 0 ? (
|
||||||
|
<PaginationBar
|
||||||
|
page={page.page}
|
||||||
|
pageSize={page.data.pageSize || 10}
|
||||||
|
total={total}
|
||||||
|
onPageChange={page.setPage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</DataState>
|
</DataState>
|
||||||
</div>
|
</div>
|
||||||
@ -155,7 +177,6 @@ export function HostAgenciesPage() {
|
|||||||
disabled={createDisabled}
|
disabled={createDisabled}
|
||||||
loading={page.loadingAction === "agency"}
|
loading={page.loadingAction === "agency"}
|
||||||
open={page.activeAction === "agency"}
|
open={page.activeAction === "agency"}
|
||||||
sectionTitle="Agency 信息"
|
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitAgency}
|
onSubmit={page.submitAgency}
|
||||||
title="创建 Agency"
|
title="创建 Agency"
|
||||||
@ -190,15 +211,17 @@ export function HostAgenciesPage() {
|
|||||||
value={page.agencyForm.maxHosts}
|
value={page.agencyForm.maxHosts}
|
||||||
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, maxHosts: event.target.value })}
|
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, maxHosts: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<AdminSwitch
|
<FormControlLabel
|
||||||
checked={page.agencyForm.joinEnabled}
|
control={
|
||||||
checkedLabel="允许"
|
<Switch
|
||||||
disabled={createDisabled}
|
checked={page.agencyForm.joinEnabled}
|
||||||
label="入会状态"
|
disabled={createDisabled}
|
||||||
uncheckedLabel="禁止"
|
onChange={(event) =>
|
||||||
onChange={(event) =>
|
page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })
|
||||||
page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })
|
}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
|
label={page.agencyForm.joinEnabled ? "允许入会" : "禁止入会"}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={createDisabled}
|
disabled={createDisabled}
|
||||||
@ -214,7 +237,6 @@ export function HostAgenciesPage() {
|
|||||||
disabled={statusDisabled}
|
disabled={statusDisabled}
|
||||||
loading={page.loadingAction === "agency-join"}
|
loading={page.loadingAction === "agency-join"}
|
||||||
open={page.activeAction === "agency-join"}
|
open={page.activeAction === "agency-join"}
|
||||||
sectionTitle="入会设置"
|
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitJoinEnabled}
|
onSubmit={page.submitJoinEnabled}
|
||||||
title="入会开关"
|
title="入会开关"
|
||||||
@ -229,15 +251,17 @@ export function HostAgenciesPage() {
|
|||||||
page.setJoinEnabledForm({ ...page.joinEnabledForm, agencyId: event.target.value })
|
page.setJoinEnabledForm({ ...page.joinEnabledForm, agencyId: event.target.value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<AdminSwitch
|
<FormControlLabel
|
||||||
checked={page.joinEnabledForm.joinEnabled}
|
control={
|
||||||
checkedLabel="允许"
|
<Switch
|
||||||
disabled={statusDisabled}
|
checked={page.joinEnabledForm.joinEnabled}
|
||||||
label="入会状态"
|
disabled={statusDisabled}
|
||||||
uncheckedLabel="禁止"
|
onChange={(event) =>
|
||||||
onChange={(event) =>
|
page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked })
|
||||||
page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked })
|
}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
|
label={page.joinEnabledForm.joinEnabled ? "允许入会" : "禁止入会"}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={statusDisabled}
|
disabled={statusDisabled}
|
||||||
@ -255,7 +279,6 @@ export function HostAgenciesPage() {
|
|||||||
disabled={statusDisabled}
|
disabled={statusDisabled}
|
||||||
loading={page.loadingAction === "agency-close"}
|
loading={page.loadingAction === "agency-close"}
|
||||||
open={page.activeAction === "agency-close"}
|
open={page.activeAction === "agency-close"}
|
||||||
sectionTitle="关闭信息"
|
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitClose}
|
onSubmit={page.submitClose}
|
||||||
submitLabel="确认关闭"
|
submitLabel="确认关闭"
|
||||||
@ -305,19 +328,3 @@ 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,12 +1,14 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
import PersonAddAlt1Outlined from "@mui/icons-material/PersonAddAlt1Outlined";
|
import PersonAddAlt1Outlined from "@mui/icons-material/PersonAddAlt1Outlined";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import Switch from "@mui/material/Switch";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { SideDrawer } from "@/shared/ui/SideDrawer.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 { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
||||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||||
@ -86,6 +88,19 @@ export function HostBdLeadersPage() {
|
|||||||
<div className={styles.contentPanel}>
|
<div className={styles.contentPanel}>
|
||||||
<HostOrgToolbar
|
<HostOrgToolbar
|
||||||
actions={toolbarActions}
|
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}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
@ -95,18 +110,16 @@ export function HostBdLeadersPage() {
|
|||||||
context={{ page, regionOptions: page.regionOptions }}
|
context={{ page, regionOptions: page.regionOptions }}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1120px"
|
minWidth="1120px"
|
||||||
pagination={
|
|
||||||
total > 0
|
|
||||||
? {
|
|
||||||
page: page.page,
|
|
||||||
pageSize: page.data.pageSize || 10,
|
|
||||||
total,
|
|
||||||
onPageChange: page.setPage,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rowKey={(item) => `bd-leader-${item.userId}`}
|
rowKey={(item) => `bd-leader-${item.userId}`}
|
||||||
/>
|
/>
|
||||||
|
{total > 0 ? (
|
||||||
|
<PaginationBar
|
||||||
|
page={page.page}
|
||||||
|
pageSize={page.data.pageSize || 10}
|
||||||
|
total={total}
|
||||||
|
onPageChange={page.setPage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</DataState>
|
</DataState>
|
||||||
</div>
|
</div>
|
||||||
@ -115,7 +128,6 @@ export function HostBdLeadersPage() {
|
|||||||
disabled={createDisabled}
|
disabled={createDisabled}
|
||||||
loading={page.loadingAction === "bd-leader"}
|
loading={page.loadingAction === "bd-leader"}
|
||||||
open={page.activeAction === "bd-leader"}
|
open={page.activeAction === "bd-leader"}
|
||||||
sectionTitle="Leader 信息"
|
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitBDLeader}
|
onSubmit={page.submitBDLeader}
|
||||||
title="创建 BD Leader"
|
title="创建 BD Leader"
|
||||||
@ -151,7 +163,6 @@ export function HostBdLeadersPage() {
|
|||||||
disabled={createDisabled}
|
disabled={createDisabled}
|
||||||
loading={page.loadingAction === "leader-add-bd"}
|
loading={page.loadingAction === "leader-add-bd"}
|
||||||
open={page.activeAction === "leader-add-bd"}
|
open={page.activeAction === "leader-add-bd"}
|
||||||
sectionTitle="下级 BD 信息"
|
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitLeaderBD}
|
onSubmit={page.submitLeaderBD}
|
||||||
title="增加下级 BD"
|
title="增加下级 BD"
|
||||||
@ -247,7 +258,7 @@ function CreatorIdentity({ item }) {
|
|||||||
|
|
||||||
function BDLeaderStatusSwitch({ item, page }) {
|
function BDLeaderStatusSwitch({ item, page }) {
|
||||||
return (
|
return (
|
||||||
<AdminSwitch
|
<Switch
|
||||||
checked={item.status === "active"}
|
checked={item.status === "active"}
|
||||||
disabled={!page?.abilities.canUpdate || page.loadingAction === `bd-leader-status-${item.userId}`}
|
disabled={!page?.abilities.canUpdate || page.loadingAction === `bd-leader-status-${item.userId}`}
|
||||||
inputProps={{ "aria-label": item.status === "active" ? "停用 BD Leader" : "启用 BD Leader" }}
|
inputProps={{ "aria-label": item.status === "active" ? "停用 BD Leader" : "启用 BD Leader" }}
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import SyncAltOutlined from "@mui/icons-material/SyncAltOutlined";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
import { bdStatusFilters } from "@/features/host-org/constants.js";
|
import { bdStatusFilters } from "@/features/host-org/constants.js";
|
||||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.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 { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||||
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
||||||
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
|
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
|
||||||
@ -35,7 +38,7 @@ const bdColumns = [
|
|||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
label: "状态",
|
label: "状态",
|
||||||
render: (item, _index, context) => <BDStatusSwitch item={item} page={context?.page} />,
|
render: (item) => <HostOrgStatus value={item.status} />,
|
||||||
width: "minmax(90px, 0.7fr)",
|
width: "minmax(90px, 0.7fr)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -61,9 +64,13 @@ const bdColumns = [
|
|||||||
export function HostBdsPage() {
|
export function HostBdsPage() {
|
||||||
const page = useHostBdsPage({ profileType: "bd" });
|
const page = useHostBdsPage({ profileType: "bd" });
|
||||||
const createDisabled = !page.abilities.canCreate;
|
const createDisabled = !page.abilities.canCreate;
|
||||||
|
const updateDisabled = !page.abilities.canUpdate;
|
||||||
const items = page.data.items || [];
|
const items = page.data.items || [];
|
||||||
const total = page.data.total || 0;
|
const total = page.data.total || 0;
|
||||||
const toolbarActions = [
|
const toolbarActions = [
|
||||||
|
page.abilities.canUpdate
|
||||||
|
? { icon: <SyncAltOutlined fontSize="small" />, label: "BD 状态", onClick: page.openBDStatusForm }
|
||||||
|
: null,
|
||||||
page.abilities.canCreate
|
page.abilities.canCreate
|
||||||
? { icon: <Add fontSize="small" />, label: "创建 BD", onClick: page.openBDForm, variant: "primary" }
|
? { icon: <Add fontSize="small" />, label: "创建 BD", onClick: page.openBDForm, variant: "primary" }
|
||||||
: null,
|
: null,
|
||||||
@ -108,27 +115,46 @@ export function HostBdsPage() {
|
|||||||
<div className={styles.contentPanel}>
|
<div className={styles.contentPanel}>
|
||||||
<HostOrgToolbar
|
<HostOrgToolbar
|
||||||
actions={toolbarActions}
|
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}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<div className={styles.listBlock}>
|
<div className={styles.listBlock}>
|
||||||
<HostOrgTable
|
<HostOrgTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
context={{ page, regionOptions: page.regionOptions }}
|
context={{ regionOptions: page.regionOptions }}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1260px"
|
minWidth="1260px"
|
||||||
pagination={
|
|
||||||
total > 0
|
|
||||||
? {
|
|
||||||
page: page.page,
|
|
||||||
pageSize: page.data.pageSize || 10,
|
|
||||||
total,
|
|
||||||
onPageChange: page.setPage,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rowKey={(item) => `bd-${item.userId}`}
|
rowKey={(item) => `bd-${item.userId}`}
|
||||||
/>
|
/>
|
||||||
|
{total > 0 ? (
|
||||||
|
<PaginationBar
|
||||||
|
page={page.page}
|
||||||
|
pageSize={page.data.pageSize || 10}
|
||||||
|
total={total}
|
||||||
|
onPageChange={page.setPage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</DataState>
|
</DataState>
|
||||||
</div>
|
</div>
|
||||||
@ -137,7 +163,6 @@ export function HostBdsPage() {
|
|||||||
disabled={createDisabled}
|
disabled={createDisabled}
|
||||||
loading={page.loadingAction === "bd"}
|
loading={page.loadingAction === "bd"}
|
||||||
open={page.activeAction === "bd"}
|
open={page.activeAction === "bd"}
|
||||||
sectionTitle="BD 信息"
|
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitBD}
|
onSubmit={page.submitBD}
|
||||||
title="创建 BD"
|
title="创建 BD"
|
||||||
@ -167,6 +192,45 @@ export function HostBdsPage() {
|
|||||||
onChange={(event) => page.setBDForm({ ...page.bdForm, reason: event.target.value })}
|
onChange={(event) => page.setBDForm({ ...page.bdForm, reason: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</HostOrgActionModal>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -196,17 +260,6 @@ 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 }) {
|
function Creator({ item }) {
|
||||||
if (!item.createdByUserId && !item.createdByDisplayUserId && !item.createdByUsername) {
|
if (!item.createdByUserId && !item.createdByDisplayUserId && !item.createdByUsername) {
|
||||||
return "-";
|
return "-";
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import Switch from "@mui/material/Switch";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import {
|
import {
|
||||||
AdminFormAmountField,
|
AdminFormAmountField,
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
|
import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
|
||||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
@ -130,6 +131,19 @@ export function HostCoinSellersPage() {
|
|||||||
<div className={styles.contentPanel}>
|
<div className={styles.contentPanel}>
|
||||||
<HostOrgToolbar
|
<HostOrgToolbar
|
||||||
actions={toolbarActions}
|
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}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
@ -139,19 +153,17 @@ export function HostCoinSellersPage() {
|
|||||||
context={{ page, regionOptions: page.regionOptions }}
|
context={{ page, regionOptions: page.regionOptions }}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1040px"
|
minWidth="1040px"
|
||||||
pagination={
|
|
||||||
total > 0
|
|
||||||
? {
|
|
||||||
page: page.page,
|
|
||||||
pageSize: page.data.pageSize || 10,
|
|
||||||
total,
|
|
||||||
onPageChange: page.setPage,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
regionFilter={regionFilter}
|
regionFilter={regionFilter}
|
||||||
rowKey={(item) => item.userId}
|
rowKey={(item) => item.userId}
|
||||||
/>
|
/>
|
||||||
|
{total > 0 ? (
|
||||||
|
<PaginationBar
|
||||||
|
page={page.page}
|
||||||
|
pageSize={page.data.pageSize || 10}
|
||||||
|
total={total}
|
||||||
|
onPageChange={page.setPage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</DataState>
|
</DataState>
|
||||||
</div>
|
</div>
|
||||||
@ -160,7 +172,6 @@ export function HostCoinSellersPage() {
|
|||||||
disabled={createDisabled}
|
disabled={createDisabled}
|
||||||
loading={page.loadingAction === "coin-seller-create"}
|
loading={page.loadingAction === "coin-seller-create"}
|
||||||
open={page.activeAction === "create"}
|
open={page.activeAction === "create"}
|
||||||
sectionTitle="币商信息"
|
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitCreateSeller}
|
onSubmit={page.submitCreateSeller}
|
||||||
title="添加币商"
|
title="添加币商"
|
||||||
@ -185,7 +196,6 @@ export function HostCoinSellersPage() {
|
|||||||
disabled={updateDisabled}
|
disabled={updateDisabled}
|
||||||
loading={String(page.loadingAction).startsWith("coin-seller-edit")}
|
loading={String(page.loadingAction).startsWith("coin-seller-edit")}
|
||||||
open={page.activeAction === "edit"}
|
open={page.activeAction === "edit"}
|
||||||
sectionTitle="联系方式"
|
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitEditSeller}
|
onSubmit={page.submitEditSeller}
|
||||||
title="编辑联系方式"
|
title="编辑联系方式"
|
||||||
@ -203,7 +213,6 @@ export function HostCoinSellersPage() {
|
|||||||
disabled={!page.abilities.canStockCredit || page.selectedSeller?.status !== "active"}
|
disabled={!page.abilities.canStockCredit || page.selectedSeller?.status !== "active"}
|
||||||
loading={String(page.loadingAction).startsWith("coin-seller-stock")}
|
loading={String(page.loadingAction).startsWith("coin-seller-stock")}
|
||||||
open={page.activeAction === "stock"}
|
open={page.activeAction === "stock"}
|
||||||
sectioned={false}
|
|
||||||
size="standard"
|
size="standard"
|
||||||
submitLabel="确认入账"
|
submitLabel="确认入账"
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
@ -299,7 +308,7 @@ function SellerIdentity({ item }) {
|
|||||||
|
|
||||||
function SellerStatusSwitch({ item, page }) {
|
function SellerStatusSwitch({ item, page }) {
|
||||||
return (
|
return (
|
||||||
<AdminSwitch
|
<Switch
|
||||||
checked={item.status === "active"}
|
checked={item.status === "active"}
|
||||||
disabled={!page.abilities.canUpdate || page.loadingAction === `coin-seller-status-${item.userId}`}
|
disabled={!page.abilities.canUpdate || page.loadingAction === `coin-seller-status-${item.userId}`}
|
||||||
inputProps={{ "aria-label": item.status === "active" ? "停用币商" : "启用币商" }}
|
inputProps={{ "aria-label": item.status === "active" ? "停用币商" : "启用币商" }}
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import Switch from "@mui/material/Switch";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
|
AdminFilterResetButton,
|
||||||
AdminListBody,
|
AdminListBody,
|
||||||
AdminListPage,
|
AdminListPage,
|
||||||
AdminListToolbar,
|
AdminListToolbar,
|
||||||
@ -86,6 +88,9 @@ export function HostCountriesPage() {
|
|||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<AdminFilterResetButton disabled={!page.query && !page.enabledStatus} onClick={page.resetFilters} />
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
page.abilities.canCreate ? (
|
page.abilities.canCreate ? (
|
||||||
<AdminActionIconButton label="创建国家" primary onClick={page.openCreateCountry}>
|
<AdminActionIconButton label="创建国家" primary onClick={page.openCreateCountry}>
|
||||||
@ -108,7 +113,6 @@ export function HostCountriesPage() {
|
|||||||
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
|
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
|
||||||
loading={page.loadingAction === "country-create" || page.loadingAction === "country-edit"}
|
loading={page.loadingAction === "country-create" || page.loadingAction === "country-edit"}
|
||||||
open={page.activeAction === "create" || page.activeAction === "edit"}
|
open={page.activeAction === "create" || page.activeAction === "edit"}
|
||||||
sectionTitle="国家信息"
|
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitCountry}
|
onSubmit={page.submitCountry}
|
||||||
title={page.activeAction === "edit" ? "编辑国家" : "创建国家"}
|
title={page.activeAction === "edit" ? "编辑国家" : "创建国家"}
|
||||||
@ -170,15 +174,17 @@ export function HostCountriesPage() {
|
|||||||
onChange={(event) => page.setCountryForm({ ...page.countryForm, sortOrder: event.target.value })}
|
onChange={(event) => page.setCountryForm({ ...page.countryForm, sortOrder: event.target.value })}
|
||||||
/>
|
/>
|
||||||
{page.activeAction === "create" ? (
|
{page.activeAction === "create" ? (
|
||||||
<AdminSwitch
|
<FormControlLabel
|
||||||
checked={page.countryForm.enabled}
|
control={
|
||||||
checkedLabel="启用"
|
<Switch
|
||||||
disabled={createDisabled}
|
checked={page.countryForm.enabled}
|
||||||
label="国家状态"
|
disabled={createDisabled}
|
||||||
uncheckedLabel="停用"
|
onChange={(event) =>
|
||||||
onChange={(event) =>
|
page.setCountryForm({ ...page.countryForm, enabled: event.target.checked })
|
||||||
page.setCountryForm({ ...page.countryForm, enabled: event.target.checked })
|
}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
|
label={page.countryForm.enabled ? "启用" : "停用"}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</HostOrgActionModal>
|
</HostOrgActionModal>
|
||||||
@ -200,7 +206,7 @@ function CountryActions({ item, page }) {
|
|||||||
|
|
||||||
function CountryStatusSwitch({ item, page }) {
|
function CountryStatusSwitch({ item, page }) {
|
||||||
return (
|
return (
|
||||||
<AdminSwitch
|
<Switch
|
||||||
checked={Boolean(item.enabled)}
|
checked={Boolean(item.enabled)}
|
||||||
disabled={!page.abilities.canStatus || page.loadingAction === `country-status-${item.countryId}`}
|
disabled={!page.abilities.canStatus || page.loadingAction === `country-status-${item.countryId}`}
|
||||||
inputProps={{ "aria-label": item.enabled ? "禁用国家" : "启用国家" }}
|
inputProps={{ "aria-label": item.enabled ? "禁用国家" : "启用国家" }}
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js";
|
import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js";
|
||||||
import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.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 { 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 { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import styles from "@/features/host-org/host-org.module.css";
|
import styles from "@/features/host-org/host-org.module.css";
|
||||||
@ -18,7 +20,7 @@ const hostColumns = [
|
|||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
label: "状态",
|
label: "状态",
|
||||||
render: (item) => <HostStatusSwitch item={item} />,
|
render: (item) => <HostOrgStatus value={item.status} />,
|
||||||
width: "minmax(90px, 0.7fr)",
|
width: "minmax(90px, 0.7fr)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -108,6 +110,30 @@ export function HostHostsPage() {
|
|||||||
return (
|
return (
|
||||||
<section className={styles.root}>
|
<section className={styles.root}>
|
||||||
<div className={styles.contentPanel}>
|
<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}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<div className={styles.listBlock}>
|
<div className={styles.listBlock}>
|
||||||
<HostOrgTable
|
<HostOrgTable
|
||||||
@ -115,18 +141,16 @@ export function HostHostsPage() {
|
|||||||
context={{ regionOptions: page.regionOptions }}
|
context={{ regionOptions: page.regionOptions }}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1240px"
|
minWidth="1240px"
|
||||||
pagination={
|
|
||||||
total > 0
|
|
||||||
? {
|
|
||||||
page: page.page,
|
|
||||||
pageSize: page.data.pageSize || 10,
|
|
||||||
total,
|
|
||||||
onPageChange: page.setPage,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rowKey={(item) => item.userId}
|
rowKey={(item) => item.userId}
|
||||||
/>
|
/>
|
||||||
|
{total > 0 ? (
|
||||||
|
<PaginationBar
|
||||||
|
page={page.page}
|
||||||
|
pageSize={page.data.pageSize || 10}
|
||||||
|
total={total}
|
||||||
|
onPageChange={page.setPage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</DataState>
|
</DataState>
|
||||||
</div>
|
</div>
|
||||||
@ -144,13 +168,3 @@ function HostUser({ item }) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HostStatusSwitch({ item }) {
|
|
||||||
return (
|
|
||||||
<AdminSwitch
|
|
||||||
checked={item.status === "active"}
|
|
||||||
disabled
|
|
||||||
inputProps={{ "aria-label": item.status === "active" ? "主播已启用" : "主播已停用" }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,168 +0,0 @@
|
|||||||
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,10 +1,11 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import Switch from "@mui/material/Switch";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
|
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
|
||||||
import { regionStatusFilters } from "@/features/host-org/constants.js";
|
import { regionStatusFilters } from "@/features/host-org/constants.js";
|
||||||
@ -80,8 +81,17 @@ export function HostRegionsPage() {
|
|||||||
return (
|
return (
|
||||||
<section className={styles.root}>
|
<section className={styles.root}>
|
||||||
<div className={styles.contentPanel}>
|
<div className={styles.contentPanel}>
|
||||||
{page.abilities.canCreate ? (
|
<div className={styles.toolbar}>
|
||||||
<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 ? (
|
||||||
<div className={styles.toolbarActions}>
|
<div className={styles.toolbarActions}>
|
||||||
<Tooltip arrow title="创建区域">
|
<Tooltip arrow title="创建区域">
|
||||||
<span>
|
<span>
|
||||||
@ -91,8 +101,8 @@ export function HostRegionsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
</div>
|
||||||
|
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<div className={styles.listBlock}>
|
<div className={styles.listBlock}>
|
||||||
@ -108,7 +118,6 @@ export function HostRegionsPage() {
|
|||||||
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
|
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
|
||||||
loading={page.loadingAction === "region-create" || page.loadingAction === "region-edit"}
|
loading={page.loadingAction === "region-create" || page.loadingAction === "region-edit"}
|
||||||
open={page.activeAction === "create" || page.activeAction === "edit"}
|
open={page.activeAction === "create" || page.activeAction === "edit"}
|
||||||
sectionTitle="区域信息"
|
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitRegion}
|
onSubmit={page.submitRegion}
|
||||||
title={page.activeAction === "edit" ? "编辑区域" : "创建区域"}
|
title={page.activeAction === "edit" ? "编辑区域" : "创建区域"}
|
||||||
@ -173,7 +182,7 @@ function RegionStatusSwitch({ item, page }) {
|
|||||||
const checked = item.status === "active";
|
const checked = item.status === "active";
|
||||||
const isGlobal = item.regionCode === "GLOBAL";
|
const isGlobal = item.regionCode === "GLOBAL";
|
||||||
return (
|
return (
|
||||||
<AdminSwitch
|
<Switch
|
||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={isGlobal || !page.abilities.canStatus || page.loadingAction === `region-status-${item.regionId}`}
|
disabled={isGlobal || !page.abilities.canStatus || page.loadingAction === `region-status-${item.regionId}`}
|
||||||
inputProps={{ "aria-label": checked ? "停用区域" : "启用区域" }}
|
inputProps={{ "aria-label": checked ? "停用区域" : "启用区域" }}
|
||||||
|
|||||||
@ -18,15 +18,7 @@ export const hostOrgRoutes = [
|
|||||||
permission: PERMISSIONS.regionView
|
permission: PERMISSIONS.regionView
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "经理列表",
|
label: "Agency 管理",
|
||||||
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),
|
loader: () => import("./pages/HostAgenciesPage.jsx").then((module) => module.HostAgenciesPage),
|
||||||
menuCode: MENU_CODES.hostOrgAgencies,
|
menuCode: MENU_CODES.hostOrgAgencies,
|
||||||
pageKey: "host-org-agencies",
|
pageKey: "host-org-agencies",
|
||||||
@ -34,7 +26,7 @@ export const hostOrgRoutes = [
|
|||||||
permission: PERMISSIONS.agencyView
|
permission: PERMISSIONS.agencyView
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "BD Leader 列表",
|
label: "BD Leader 管理",
|
||||||
loader: () => import("./pages/HostBdLeadersPage.jsx").then((module) => module.HostBdLeadersPage),
|
loader: () => import("./pages/HostBdLeadersPage.jsx").then((module) => module.HostBdLeadersPage),
|
||||||
menuCode: MENU_CODES.hostOrgBdLeaders,
|
menuCode: MENU_CODES.hostOrgBdLeaders,
|
||||||
pageKey: "host-org-bd-leaders",
|
pageKey: "host-org-bd-leaders",
|
||||||
@ -42,7 +34,7 @@ export const hostOrgRoutes = [
|
|||||||
permission: PERMISSIONS.bdView
|
permission: PERMISSIONS.bdView
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "BD 列表",
|
label: "BD 管理",
|
||||||
loader: () => import("./pages/HostBdsPage.jsx").then((module) => module.HostBdsPage),
|
loader: () => import("./pages/HostBdsPage.jsx").then((module) => module.HostBdsPage),
|
||||||
menuCode: MENU_CODES.hostOrgBds,
|
menuCode: MENU_CODES.hostOrgBds,
|
||||||
pageKey: "host-org-bds",
|
pageKey: "host-org-bds",
|
||||||
@ -50,7 +42,7 @@ export const hostOrgRoutes = [
|
|||||||
permission: PERMISSIONS.bdView
|
permission: PERMISSIONS.bdView
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "主播列表",
|
label: "主播管理",
|
||||||
loader: () => import("./pages/HostHostsPage.jsx").then((module) => module.HostHostsPage),
|
loader: () => import("./pages/HostHostsPage.jsx").then((module) => module.HostHostsPage),
|
||||||
menuCode: MENU_CODES.hostOrgHosts,
|
menuCode: MENU_CODES.hostOrgHosts,
|
||||||
pageKey: "host-org-hosts",
|
pageKey: "host-org-hosts",
|
||||||
@ -58,7 +50,7 @@ export const hostOrgRoutes = [
|
|||||||
permission: PERMISSIONS.hostView
|
permission: PERMISSIONS.hostView
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "币商列表",
|
label: "币商管理",
|
||||||
loader: () => import("./pages/HostCoinSellersPage.jsx").then((module) => module.HostCoinSellersPage),
|
loader: () => import("./pages/HostCoinSellersPage.jsx").then((module) => module.HostCoinSellersPage),
|
||||||
menuCode: MENU_CODES.hostOrgCoinSellers,
|
menuCode: MENU_CODES.hostOrgCoinSellers,
|
||||||
pageKey: "host-org-coin-sellers",
|
pageKey: "host-org-coin-sellers",
|
||||||
|
|||||||
9
src/features/logs/components/LogFilters.jsx
Normal file
9
src/features/logs/components/LogFilters.jsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
|
||||||
|
export function LogFilters({ onReset, query }) {
|
||||||
|
return (
|
||||||
|
<section className="filters-panel">
|
||||||
|
<AdminFilterResetButton disabled={!query} onClick={onReset} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import { DataTable } from "@/shared/ui/DataTable.jsx";
|
|||||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
|
||||||
export function LogTable({ data, isLogin, onQueryChange, pagination, query }) {
|
export function LogTable({ data, isLogin, onQueryChange, query }) {
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
const columns = isLogin
|
const columns = isLogin
|
||||||
? [
|
? [
|
||||||
@ -69,8 +69,9 @@ export function LogTable({ data, isLogin, onQueryChange, pagination, query }) {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth={isLogin ? "920px" : "1080px"}
|
minWidth={isLogin ? "920px" : "1080px"}
|
||||||
pagination={pagination}
|
|
||||||
rowKey={(item) => item.id}
|
rowKey={(item) => item.id}
|
||||||
|
title={isLogin ? "登录记录" : "操作记录"}
|
||||||
|
total={data.total}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
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 { LogTable } from "@/features/logs/components/LogTable.jsx";
|
||||||
import { LogToolbar } from "@/features/logs/components/LogToolbar.jsx";
|
import { LogToolbar } from "@/features/logs/components/LogToolbar.jsx";
|
||||||
import { useLogsPage } from "@/features/logs/hooks/useLogsPage.js";
|
import { useLogsPage } from "@/features/logs/hooks/useLogsPage.js";
|
||||||
@ -9,19 +11,15 @@ export function LogsPage({ type }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LogToolbar canExport={page.abilities.canExport} isLogin={page.isLogin} onExport={page.downloadLogs} />
|
<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}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<LogTable
|
<LogTable data={page.data} isLogin={page.isLogin} query={page.query} onQueryChange={page.changeQuery} />
|
||||||
data={page.data}
|
<PaginationBar
|
||||||
isLogin={page.isLogin}
|
page={page.page}
|
||||||
pagination={{
|
pageSize={page.data.pageSize || 10}
|
||||||
page: page.page,
|
total={page.data.total || 0}
|
||||||
pageSize: page.data.pageSize || 10,
|
onPageChange={page.setPage}
|
||||||
total: page.data.total || 0,
|
|
||||||
onPageChange: page.setPage,
|
|
||||||
}}
|
|
||||||
query={page.query}
|
|
||||||
onQueryChange={page.changeQuery}
|
|
||||||
/>
|
/>
|
||||||
</DataState>
|
</DataState>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import Drawer from "@mui/material/Drawer";
|
import Drawer from "@mui/material/Drawer";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import Switch from "@mui/material/Switch";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
|
||||||
@ -8,30 +9,17 @@ export function MenuFormDrawer({ editingMenu, form, onClose, onSubmit, open, set
|
|||||||
<Drawer anchor="right" open={open} onClose={onClose}>
|
<Drawer anchor="right" open={open} onClose={onClose}>
|
||||||
<form className="form-drawer" onSubmit={onSubmit}>
|
<form className="form-drawer" onSubmit={onSubmit}>
|
||||||
<h2>{editingMenu ? "编辑菜单" : "新增菜单"}</h2>
|
<h2>{editingMenu ? "编辑菜单" : "新增菜单"}</h2>
|
||||||
<section className="form-drawer__section">
|
<TextField label="菜单名称" required value={form.label} onChange={(event) => setForm({ ...form, label: event.target.value })} />
|
||||||
<div className="form-drawer__section-title">基础信息</div>
|
<TextField label="菜单编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
|
||||||
<div className="form-drawer__grid">
|
<TextField label="父级 ID" type="number" value={form.parentId} onChange={(event) => setForm({ ...form, parentId: event.target.value })} />
|
||||||
<TextField label="菜单名称" required value={form.label} onChange={(event) => setForm({ ...form, label: event.target.value })} />
|
<TextField label="路由" value={form.path} onChange={(event) => setForm({ ...form, path: event.target.value })} />
|
||||||
<TextField label="菜单编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
|
<TextField label="图标" value={form.icon} onChange={(event) => setForm({ ...form, icon: event.target.value })} />
|
||||||
<TextField label="父级 ID" type="number" value={form.parentId} onChange={(event) => setForm({ ...form, parentId: 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 })} />
|
<TextField label="排序" type="number" value={form.sort} onChange={(event) => setForm({ ...form, sort: event.target.value })} />
|
||||||
<AdminSwitch
|
<FormControlLabel
|
||||||
checked={form.visible}
|
control={<Switch checked={form.visible} onChange={(event) => setForm({ ...form, visible: event.target.checked })} />}
|
||||||
checkedLabel="显示"
|
label={form.visible ? "显示" : "隐藏"}
|
||||||
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">
|
<div className="form-drawer__actions">
|
||||||
<Button onClick={onClose}>取消</Button>
|
<Button onClick={onClose}>取消</Button>
|
||||||
<Button type="submit" variant="primary">提交</Button>
|
<Button type="submit" variant="primary">提交</Button>
|
||||||
|
|||||||
@ -9,22 +9,14 @@ export function PermissionFormDrawer({ editingPermission, form, onClose, onSubmi
|
|||||||
<Drawer anchor="right" open={open} onClose={onClose}>
|
<Drawer anchor="right" open={open} onClose={onClose}>
|
||||||
<form className="form-drawer" onSubmit={onSubmit}>
|
<form className="form-drawer" onSubmit={onSubmit}>
|
||||||
<h2>{editingPermission ? "编辑权限" : "新增权限"}</h2>
|
<h2>{editingPermission ? "编辑权限" : "新增权限"}</h2>
|
||||||
<section className="form-drawer__section">
|
<TextField label="权限名称" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
||||||
<div className="form-drawer__section-title">基础信息</div>
|
<TextField label="权限编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
|
||||||
<div className="form-drawer__grid">
|
<TextField label="类型" required select value={form.kind} onChange={(event) => setForm({ ...form, kind: event.target.value })}>
|
||||||
<TextField label="权限名称" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
{permissionKinds.map(([value, label]) => (
|
||||||
<TextField label="权限编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
|
<MenuItem key={value} value={value}>{label}</MenuItem>
|
||||||
<TextField label="类型" required select value={form.kind} onChange={(event) => setForm({ ...form, kind: event.target.value })}>
|
))}
|
||||||
{permissionKinds.map(([value, label]) => (
|
</TextField>
|
||||||
<MenuItem key={value} value={value}>{label}</MenuItem>
|
<TextField label="说明" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
|
||||||
))}
|
|
||||||
</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">
|
<div className="form-drawer__actions">
|
||||||
<Button onClick={onClose}>取消</Button>
|
<Button onClick={onClose}>取消</Button>
|
||||||
<Button type="submit" variant="primary">提交</Button>
|
<Button type="submit" variant="primary">提交</Button>
|
||||||
|
|||||||
28
src/features/notifications/api.ts
Normal file
28
src/features/notifications/api.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { apiRequest } from "@/shared/api/request";
|
||||||
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
|
import type { EntityId, NotificationDto, NotificationListDto } from "@/shared/api/types";
|
||||||
|
|
||||||
|
export function listNotifications(): Promise<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 });
|
||||||
|
}
|
||||||
40
src/features/notifications/components/NotificationList.jsx
Normal file
40
src/features/notifications/components/NotificationList.jsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
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";
|
||||||
|
import { TimeText } from "@/shared/ui/TimeText.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>
|
||||||
|
<TimeText className="muted" component="div" value={item.createdAtMs ?? item.createdAt} />
|
||||||
|
</div>
|
||||||
|
<div className="row-actions">
|
||||||
|
{!item.readAtMs && !item.readAt && abilities.canRead ? (
|
||||||
|
<Button onClick={() => onRead(item.id)}>
|
||||||
|
<DoneAllOutlined fontSize="small" />
|
||||||
|
已读
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="muted">{item.readAtMs || 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
import DoneAllOutlined from "@mui/icons-material/DoneAllOutlined";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { PageHead } from "@/shared/ui/PageHead.jsx";
|
||||||
|
|
||||||
|
export function NotificationToolbar({ canReadAll, onReadAll, unread }) {
|
||||||
|
return (
|
||||||
|
<PageHead title="通知中心" meta={`未读 ${unread || 0} 条`}>
|
||||||
|
{canReadAll ? (
|
||||||
|
<Button onClick={onReadAll}>
|
||||||
|
<DoneAllOutlined fontSize="small" />
|
||||||
|
全部已读
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</PageHead>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/features/notifications/hooks/useNotificationsPage.js
Normal file
62
src/features/notifications/hooks/useNotificationsPage.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
import {
|
||||||
|
deleteNotification,
|
||||||
|
listNotifications,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
markNotificationRead
|
||||||
|
} from "@/features/notifications/api";
|
||||||
|
import { useNotificationAbilities } from "@/features/notifications/permissions.js";
|
||||||
|
|
||||||
|
const emptyData = { items: [], unread: 0 };
|
||||||
|
|
||||||
|
export function useNotificationsPage() {
|
||||||
|
const abilities = useNotificationAbilities();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
const queryFn = useCallback(() => {
|
||||||
|
return listNotifications();
|
||||||
|
}, []);
|
||||||
|
const { data, error, loading, reload } = useAdminQuery(queryFn, {
|
||||||
|
errorMessage: "加载通知失败",
|
||||||
|
initialData: emptyData,
|
||||||
|
keepPreviousData: true,
|
||||||
|
queryKey: ["notifications"]
|
||||||
|
});
|
||||||
|
|
||||||
|
const read = async (id) => {
|
||||||
|
await markNotificationRead(id);
|
||||||
|
showToast("通知已标记为已读", "success");
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const readAll = async () => {
|
||||||
|
await markAllNotificationsRead();
|
||||||
|
showToast("通知已全部标记为已读", "success");
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (item) => {
|
||||||
|
const ok = await confirm({ confirmText: "删除", message: item.title, title: "删除通知", tone: "danger" });
|
||||||
|
if (!ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deleteNotification(item.id);
|
||||||
|
showToast("通知已删除", "success");
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
data: data || emptyData,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
read,
|
||||||
|
readAll,
|
||||||
|
reload,
|
||||||
|
remove
|
||||||
|
};
|
||||||
|
}
|
||||||
25
src/features/notifications/notifications.css
Normal file
25
src/features/notifications/notifications.css
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
.notification-list {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-height: 86px;
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
border-bottom: 1px solid var(--row-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
}
|
||||||
17
src/features/notifications/pages/NotificationsPage.jsx
Normal file
17
src/features/notifications/pages/NotificationsPage.jsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { NotificationList } from "@/features/notifications/components/NotificationList.jsx";
|
||||||
|
import { NotificationToolbar } from "@/features/notifications/components/NotificationToolbar.jsx";
|
||||||
|
import { useNotificationsPage } from "@/features/notifications/hooks/useNotificationsPage.js";
|
||||||
|
|
||||||
|
export function NotificationsPage() {
|
||||||
|
const page = useNotificationsPage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/features/notifications/permissions.js
Normal file
12
src/features/notifications/permissions.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export function useNotificationAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canDelete: can(PERMISSIONS.notificationDelete),
|
||||||
|
canRead: can(PERMISSIONS.notificationRead),
|
||||||
|
canReadAll: can(PERMISSIONS.notificationReadAll)
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/features/notifications/routes.js
Normal file
12
src/features/notifications/routes.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export const notificationsRoutes = [
|
||||||
|
{
|
||||||
|
label: "通知中心",
|
||||||
|
loader: () => import("./pages/NotificationsPage.jsx").then((module) => module.NotificationsPage),
|
||||||
|
menuCode: MENU_CODES.notifications,
|
||||||
|
pageKey: "notifications",
|
||||||
|
path: "/notifications",
|
||||||
|
permission: PERMISSIONS.notificationView
|
||||||
|
}
|
||||||
|
];
|
||||||
@ -1,11 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,170 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,509 +0,0 @@
|
|||||||
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 || "-";
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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,58 +1,11 @@
|
|||||||
import { apiRequest } from "@/shared/api/request";
|
import { apiRequest } from "@/shared/api/request";
|
||||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
import type {
|
import type { ApiPage, PageQuery, RechargeBillDto } from "@/shared/api/types";
|
||||||
ApiPage,
|
|
||||||
EntityId,
|
|
||||||
PageQuery,
|
|
||||||
RechargeBillDto,
|
|
||||||
RechargeProductDto,
|
|
||||||
RechargeProductPayload,
|
|
||||||
} from "@/shared/api/types";
|
|
||||||
|
|
||||||
export function listRechargeBills(query: PageQuery = {}): Promise<ApiPage<RechargeBillDto>> {
|
export function listRechargeBills(query: PageQuery = {}): Promise<ApiPage<RechargeBillDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listRechargeBills;
|
const endpoint = API_ENDPOINTS.listRechargeBills;
|
||||||
return apiRequest<ApiPage<RechargeBillDto>>(apiEndpointPath(API_OPERATIONS.listRechargeBills), {
|
return apiRequest<ApiPage<RechargeBillDto>>(apiEndpointPath(API_OPERATIONS.listRechargeBills), {
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
query,
|
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,197 +0,0 @@
|
|||||||
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,11 +1,14 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { listRechargeBills } from "@/features/payment/api";
|
import { listRechargeBills } from "@/features/payment/api";
|
||||||
import {
|
import {
|
||||||
|
AdminFilterResetButton,
|
||||||
AdminListBody,
|
AdminListBody,
|
||||||
AdminListPage,
|
AdminListPage,
|
||||||
|
AdminListToolbar,
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
@ -127,6 +130,14 @@ export function PaymentBillListPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setKeyword("");
|
||||||
|
setStatus("");
|
||||||
|
setRechargeType("");
|
||||||
|
setUserId("");
|
||||||
|
setSellerUserId("");
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
const tableColumns = columns.map((column) => {
|
const tableColumns = columns.map((column) => {
|
||||||
if (column.key === "bill") {
|
if (column.key === "bill") {
|
||||||
return {
|
return {
|
||||||
@ -185,24 +196,30 @@ export function PaymentBillListPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<AdminFilterResetButton
|
||||||
|
disabled={!keyword && !status && !rechargeType && !userId && !sellerUserId}
|
||||||
|
onClick={resetFilters}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1260px"
|
minWidth="1260px"
|
||||||
pagination={
|
|
||||||
total > 0
|
|
||||||
? {
|
|
||||||
page,
|
|
||||||
pageSize: data.pageSize || pageSize,
|
|
||||||
total,
|
|
||||||
onPageChange: setPage,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rowKey={(bill) => bill.transactionId}
|
rowKey={(bill) => bill.transactionId}
|
||||||
/>
|
/>
|
||||||
|
{total > 0 ? (
|
||||||
|
<PaginationBar
|
||||||
|
page={page}
|
||||||
|
pageSize={data.pageSize || pageSize}
|
||||||
|
total={total}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
|
|||||||
@ -1,340 +0,0 @@
|
|||||||
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+$/, "");
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
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";
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
export const paymentRoutes = [
|
export const paymentRoutes = [
|
||||||
{
|
{
|
||||||
label: "账单列表",
|
label: "账单列表",
|
||||||
loader: () => import("./pages/PaymentBillListPage.jsx").then((module) => module.PaymentBillListPage),
|
loader: () => import("./pages/PaymentBillListPage.jsx").then((module) => module.PaymentBillListPage),
|
||||||
menuCode: MENU_CODES.paymentBillList,
|
menuCode: MENU_CODES.paymentBillList,
|
||||||
pageKey: "payment-bill-list",
|
pageKey: "payment-bill-list",
|
||||||
path: "/payment/bills",
|
path: "/payment/bills",
|
||||||
permission: PERMISSIONS.paymentBillView,
|
permission: PERMISSIONS.paymentBillView
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
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>;
|
|
||||||
@ -1,133 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,278 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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,15 +115,6 @@ export interface ResourceGrantItemDto {
|
|||||||
walletTransactionId?: string;
|
walletTransactionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceGrantOperatorDto {
|
|
||||||
avatar?: string;
|
|
||||||
displayUserId?: string;
|
|
||||||
name?: string;
|
|
||||||
source?: string;
|
|
||||||
userId?: number | string;
|
|
||||||
username?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResourceGrantDto {
|
export interface ResourceGrantDto {
|
||||||
appCode?: string;
|
appCode?: string;
|
||||||
commandId?: string;
|
commandId?: string;
|
||||||
@ -132,7 +123,6 @@ export interface ResourceGrantDto {
|
|||||||
grantSource?: string;
|
grantSource?: string;
|
||||||
grantSubjectId?: string;
|
grantSubjectId?: string;
|
||||||
grantSubjectType?: string;
|
grantSubjectType?: string;
|
||||||
operator?: ResourceGrantOperatorDto;
|
|
||||||
items?: ResourceGrantItemDto[];
|
items?: ResourceGrantItemDto[];
|
||||||
operatorUserId?: number;
|
operatorUserId?: number;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
|||||||
@ -26,7 +26,6 @@ export const resourceGrantSubjectLabels = {
|
|||||||
export const resourceTypeFilters = [
|
export const resourceTypeFilters = [
|
||||||
["", "全部类型"],
|
["", "全部类型"],
|
||||||
["avatar_frame", "头像框"],
|
["avatar_frame", "头像框"],
|
||||||
["profile_card", "资料卡"],
|
|
||||||
["coin", "金币"],
|
["coin", "金币"],
|
||||||
["vehicle", "座驾"],
|
["vehicle", "座驾"],
|
||||||
["chat_bubble", "气泡"],
|
["chat_bubble", "气泡"],
|
||||||
|
|||||||
@ -85,28 +85,6 @@ const emptyGiftForm = (gift = {}) => ({
|
|||||||
resourceId: gift.resourceId ? String(gift.resourceId) : "",
|
resourceId: gift.resourceId ? String(gift.resourceId) : "",
|
||||||
sortOrder: gift.sortOrder === 0 || gift.sortOrder ? String(gift.sortOrder) : "0",
|
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 = () => ({
|
const emptyGrantForm = () => ({
|
||||||
durationDays: "0",
|
durationDays: "0",
|
||||||
groupId: "",
|
groupId: "",
|
||||||
@ -524,20 +502,6 @@ export function useGiftListPage() {
|
|||||||
setSelectedGift(null);
|
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) => {
|
const submitGift = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const editing = activeAction === "edit";
|
const editing = activeAction === "edit";
|
||||||
@ -596,7 +560,6 @@ export function useGiftListPage() {
|
|||||||
...sharedPageState({ page, query, result, setPage, setQuery }),
|
...sharedPageState({ page, query, result, setPage, setQuery }),
|
||||||
abilities,
|
abilities,
|
||||||
activeAction,
|
activeAction,
|
||||||
changeGiftPrice,
|
|
||||||
changeRegionId: resetSetter(setRegionId, setPage),
|
changeRegionId: resetSetter(setRegionId, setPage),
|
||||||
changeStatus: resetSetter(setStatus, setPage),
|
changeStatus: resetSetter(setStatus, setPage),
|
||||||
closeAction,
|
closeAction,
|
||||||
@ -611,7 +574,6 @@ export function useGiftListPage() {
|
|||||||
resourceOptionsLoading,
|
resourceOptionsLoading,
|
||||||
selectedGift,
|
selectedGift,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
selectGiftResource,
|
|
||||||
setForm,
|
setForm,
|
||||||
status,
|
status,
|
||||||
submitGift,
|
submitGift,
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
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,28 +6,26 @@ import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined";
|
|||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import InputAdornment from "@mui/material/InputAdornment";
|
import InputAdornment from "@mui/material/InputAdornment";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import Popover from "@mui/material/Popover";
|
import Popover from "@mui/material/Popover";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import Switch from "@mui/material/Switch";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import { AdminFormDialog, AdminFormFieldGrid } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
AdminFormDialog,
|
|
||||||
AdminFormFieldGrid,
|
|
||||||
AdminFormSection,
|
|
||||||
AdminFormSwitchField,
|
|
||||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
|
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
|
AdminFilterResetButton,
|
||||||
AdminListBody,
|
AdminListBody,
|
||||||
AdminListPage,
|
AdminListPage,
|
||||||
AdminRowActions,
|
AdminRowActions,
|
||||||
AdminListToolbar,
|
AdminListToolbar,
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
@ -184,6 +182,12 @@ export function GiftListPage() {
|
|||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<AdminFilterResetButton
|
||||||
|
disabled={!page.query && !page.status && !page.regionId}
|
||||||
|
onClick={page.resetFilters}
|
||||||
|
/>
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
page.abilities.canCreateGift ? (
|
page.abilities.canCreateGift ? (
|
||||||
<AdminActionIconButton label="添加礼物" primary onClick={page.openCreateGift}>
|
<AdminActionIconButton label="添加礼物" primary onClick={page.openCreateGift}>
|
||||||
@ -194,22 +198,15 @@ export function GiftListPage() {
|
|||||||
/>
|
/>
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
<DataTable
|
<DataTable columns={tableColumns} items={items} minWidth="1480px" rowKey={(gift) => gift.giftId} />
|
||||||
columns={tableColumns}
|
{total > 0 ? (
|
||||||
items={items}
|
<PaginationBar
|
||||||
minWidth="1480px"
|
page={page.page}
|
||||||
pagination={
|
pageSize={page.data.pageSize || 10}
|
||||||
total > 0
|
total={total}
|
||||||
? {
|
onPageChange={page.setPage}
|
||||||
page: page.page,
|
/>
|
||||||
pageSize: page.data.pageSize || 10,
|
) : null}
|
||||||
total,
|
|
||||||
onPageChange: page.setPage,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rowKey={(gift) => gift.giftId}
|
|
||||||
/>
|
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
<GiftFormDialog
|
<GiftFormDialog
|
||||||
@ -244,7 +241,7 @@ function GiftRowActions({ gift, page }) {
|
|||||||
function GiftStatusSwitch({ gift, page }) {
|
function GiftStatusSwitch({ gift, page }) {
|
||||||
const checked = gift.status === "active";
|
const checked = gift.status === "active";
|
||||||
return (
|
return (
|
||||||
<AdminSwitch
|
<Switch
|
||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={!page.abilities.canStatusGift || page.loadingAction === `gift-status-${gift.giftId}`}
|
disabled={!page.abilities.canStatusGift || page.loadingAction === `gift-status-${gift.giftId}`}
|
||||||
inputProps={{ "aria-label": checked ? "禁用礼物" : "启用礼物" }}
|
inputProps={{ "aria-label": checked ? "禁用礼物" : "启用礼物" }}
|
||||||
@ -255,7 +252,6 @@ function GiftStatusSwitch({ gift, page }) {
|
|||||||
|
|
||||||
function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page, regionOptions }) {
|
function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page, regionOptions }) {
|
||||||
const submitDisabled = disabled || loading || page.resourceOptionsLoading || page.loadingRegions;
|
const submitDisabled = disabled || loading || page.resourceOptionsLoading || page.loadingRegions;
|
||||||
const showGiftIdentityFields = mode === "edit" || Boolean(form.resourceId);
|
|
||||||
return (
|
return (
|
||||||
<AdminFormDialog
|
<AdminFormDialog
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@ -266,140 +262,128 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
<AdminFormSection title="资源信息">
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
<TextField
|
||||||
<TextField
|
disabled={disabled}
|
||||||
disabled={disabled || page.resourceOptionsLoading}
|
label="礼物名称"
|
||||||
label="礼物资源"
|
required
|
||||||
required
|
value={form.name}
|
||||||
select
|
onChange={(event) => page.setForm({ ...form, name: event.target.value })}
|
||||||
value={form.resourceId}
|
/>
|
||||||
onChange={(event) => page.selectGiftResource(event.target.value)}
|
<TextField
|
||||||
>
|
disabled={disabled || mode === "edit"}
|
||||||
{page.resourceOptions.map((resource) => (
|
label="礼物 ID"
|
||||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
required
|
||||||
{resource.name || resource.resourceCode || resource.resourceId}
|
value={form.giftId}
|
||||||
</MenuItem>
|
onChange={(event) => page.setForm({ ...form, giftId: event.target.value })}
|
||||||
))}
|
/>
|
||||||
</TextField>
|
<TextField
|
||||||
{showGiftIdentityFields ? (
|
disabled={disabled}
|
||||||
<>
|
label="礼物类型"
|
||||||
<TextField
|
required
|
||||||
disabled={disabled}
|
select
|
||||||
label="礼物名称"
|
value={form.giftTypeCode}
|
||||||
required
|
onChange={(event) => page.setForm({ ...form, giftTypeCode: event.target.value })}
|
||||||
value={form.name}
|
>
|
||||||
onChange={(event) => page.setForm({ ...form, name: event.target.value })}
|
{giftTypeOptions.map(([value, label]) => (
|
||||||
/>
|
<MenuItem key={value} value={value}>
|
||||||
<TextField
|
{label}
|
||||||
disabled={disabled || mode === "edit"}
|
</MenuItem>
|
||||||
label="礼物 ID"
|
))}
|
||||||
required
|
</TextField>
|
||||||
value={form.giftId}
|
<TextField
|
||||||
onChange={(event) => page.setForm({ ...form, giftId: event.target.value })}
|
disabled={disabled || page.resourceOptionsLoading}
|
||||||
/>
|
label="礼物资源"
|
||||||
</>
|
required
|
||||||
) : null}
|
select
|
||||||
<TextField
|
value={form.resourceId}
|
||||||
disabled={disabled}
|
onChange={(event) => page.setForm({ ...form, resourceId: event.target.value })}
|
||||||
label="礼物类型"
|
>
|
||||||
required
|
{page.resourceOptions.map((resource) => (
|
||||||
select
|
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
||||||
value={form.giftTypeCode}
|
{resource.name || resource.resourceCode || resource.resourceId}
|
||||||
onChange={(event) => page.setForm({ ...form, giftTypeCode: event.target.value })}
|
</MenuItem>
|
||||||
>
|
))}
|
||||||
{giftTypeOptions.map(([value, label]) => (
|
</TextField>
|
||||||
<MenuItem key={value} value={value}>
|
<TextField
|
||||||
{label}
|
disabled={disabled}
|
||||||
</MenuItem>
|
label="收费类型"
|
||||||
))}
|
required
|
||||||
</TextField>
|
select
|
||||||
</AdminFormFieldGrid>
|
value={form.chargeAssetType}
|
||||||
</AdminFormSection>
|
onChange={(event) => page.setForm({ ...form, chargeAssetType: event.target.value })}
|
||||||
<AdminFormSection title="价格与排序">
|
>
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
{chargeAssetOptions.map(([value, label]) => (
|
||||||
<TextField
|
<MenuItem key={value} value={value}>
|
||||||
disabled={disabled}
|
{label}
|
||||||
label="收费类型"
|
</MenuItem>
|
||||||
required
|
))}
|
||||||
select
|
</TextField>
|
||||||
value={form.chargeAssetType}
|
<TextField
|
||||||
onChange={(event) => page.setForm({ ...form, chargeAssetType: event.target.value })}
|
disabled={disabled}
|
||||||
>
|
label="价格"
|
||||||
{chargeAssetOptions.map(([value, label]) => (
|
required
|
||||||
<MenuItem key={value} value={value}>
|
type="number"
|
||||||
{label}
|
value={form.coinPrice}
|
||||||
</MenuItem>
|
onChange={(event) => page.setForm({ ...form, coinPrice: event.target.value })}
|
||||||
))}
|
/>
|
||||||
</TextField>
|
<TextField
|
||||||
<TextField
|
disabled={disabled}
|
||||||
disabled={disabled}
|
label="积分"
|
||||||
label="价格"
|
type="number"
|
||||||
required
|
value={form.giftPointAmount}
|
||||||
type="number"
|
onChange={(event) => page.setForm({ ...form, giftPointAmount: event.target.value })}
|
||||||
value={form.coinPrice}
|
/>
|
||||||
onChange={(event) => page.changeGiftPrice(event.target.value)}
|
<TextField
|
||||||
/>
|
disabled={disabled}
|
||||||
<TextField
|
label="热度"
|
||||||
disabled={disabled}
|
type="number"
|
||||||
label="积分"
|
value={form.heatValue}
|
||||||
type="number"
|
onChange={(event) => page.setForm({ ...form, heatValue: event.target.value })}
|
||||||
value={form.giftPointAmount}
|
/>
|
||||||
onChange={(event) => page.setForm({ ...form, giftPointAmount: event.target.value })}
|
<TextField
|
||||||
/>
|
disabled={disabled}
|
||||||
<TextField
|
label="排序"
|
||||||
disabled={disabled}
|
type="number"
|
||||||
label="热度"
|
value={form.sortOrder}
|
||||||
type="number"
|
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
|
||||||
value={form.heatValue}
|
/>
|
||||||
onChange={(event) => page.setForm({ ...form, heatValue: event.target.value })}
|
<GiftRegionSelect
|
||||||
/>
|
disabled={disabled || page.loadingRegions}
|
||||||
<TextField
|
labels={buildRegionLabelMap(regionOptions)}
|
||||||
disabled={disabled}
|
options={regionOptions}
|
||||||
label="排序"
|
value={form.regionIds}
|
||||||
type="number"
|
onChange={(regionIds) => page.setForm({ ...form, regionIds })}
|
||||||
value={form.sortOrder}
|
/>
|
||||||
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
|
<DateTimeField
|
||||||
/>
|
disabled={disabled}
|
||||||
<AdminFormSwitchField
|
label="有效开始时间"
|
||||||
checked={form.enabled}
|
value={form.effectiveFrom}
|
||||||
checkedLabel="启用"
|
onChange={(effectiveFrom) => page.setForm({ ...form, effectiveFrom })}
|
||||||
className={styles.giftSwitch}
|
/>
|
||||||
disabled={disabled}
|
<DateTimeField
|
||||||
label="礼物状态"
|
disabled={disabled}
|
||||||
uncheckedLabel="禁用"
|
label="有效结束时间"
|
||||||
onChange={(checked) => page.setForm({ ...form, enabled: checked })}
|
value={form.effectiveTo}
|
||||||
/>
|
onChange={(effectiveTo) => page.setForm({ ...form, effectiveTo })}
|
||||||
</AdminFormFieldGrid>
|
/>
|
||||||
</AdminFormSection>
|
<GiftEffectSelect
|
||||||
<AdminFormSection title="投放与时间">
|
disabled={disabled}
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
value={form.effectTypes}
|
||||||
<GiftRegionSelect
|
onChange={(effectTypes) => page.setForm({ ...form, effectTypes })}
|
||||||
disabled={disabled || page.loadingRegions}
|
/>
|
||||||
labels={buildRegionLabelMap(regionOptions)}
|
<FormControlLabel
|
||||||
options={regionOptions}
|
className={styles.giftSwitch}
|
||||||
value={form.regionIds}
|
control={
|
||||||
onChange={(regionIds) => page.setForm({ ...form, regionIds })}
|
<Switch
|
||||||
/>
|
checked={form.enabled}
|
||||||
<DateTimeField
|
disabled={disabled}
|
||||||
disabled={disabled}
|
onChange={(event) => page.setForm({ ...form, enabled: event.target.checked })}
|
||||||
label="有效开始时间"
|
/>
|
||||||
value={form.effectiveFrom}
|
}
|
||||||
onChange={(effectiveFrom) => page.setForm({ ...form, effectiveFrom })}
|
label={form.enabled ? "启用" : "禁用"}
|
||||||
/>
|
/>
|
||||||
<DateTimeField
|
</AdminFormFieldGrid>
|
||||||
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>
|
</AdminFormDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
|
AdminFilterResetButton,
|
||||||
AdminListBody,
|
AdminListBody,
|
||||||
AdminListPage,
|
AdminListPage,
|
||||||
AdminListToolbar,
|
AdminListToolbar,
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import {
|
import {
|
||||||
@ -39,12 +40,6 @@ const grantColumns = [
|
|||||||
width: "minmax(120px, 0.7fr)",
|
width: "minmax(120px, 0.7fr)",
|
||||||
render: (grant) => grant.targetUserId || "-",
|
render: (grant) => grant.targetUserId || "-",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "operator",
|
|
||||||
label: "操作人",
|
|
||||||
width: "minmax(180px, 1fr)",
|
|
||||||
render: (grant) => <GrantOperator grant={grant} />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "subject",
|
key: "subject",
|
||||||
label: "对象",
|
label: "对象",
|
||||||
@ -119,6 +114,9 @@ export function ResourceGrantListPage() {
|
|||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<AdminFilterResetButton disabled={!page.query && !page.status} onClick={page.resetFilters} />
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
page.abilities.canCreateGrant ? (
|
page.abilities.canCreateGrant ? (
|
||||||
<AdminActionIconButton label="资源赠送" primary onClick={page.openCreateGrant}>
|
<AdminActionIconButton label="资源赠送" primary onClick={page.openCreateGrant}>
|
||||||
@ -132,19 +130,17 @@ export function ResourceGrantListPage() {
|
|||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1370px"
|
minWidth="1190px"
|
||||||
pagination={
|
|
||||||
total > 0
|
|
||||||
? {
|
|
||||||
page: page.page,
|
|
||||||
pageSize: page.data.pageSize || 10,
|
|
||||||
total,
|
|
||||||
onPageChange: page.setPage,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rowKey={(grant) => grant.grantId}
|
rowKey={(grant) => grant.grantId}
|
||||||
/>
|
/>
|
||||||
|
{total > 0 ? (
|
||||||
|
<PaginationBar
|
||||||
|
page={page.page}
|
||||||
|
pageSize={page.data.pageSize || 10}
|
||||||
|
total={total}
|
||||||
|
onPageChange={page.setPage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
<ResourceGrantDialog
|
<ResourceGrantDialog
|
||||||
@ -185,92 +181,82 @@ function ResourceGrantDialog({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
<AdminFormSection title="赠送对象">
|
<TextField
|
||||||
<TextField
|
disabled={disabled}
|
||||||
disabled={disabled}
|
label="用户 ID"
|
||||||
label="用户 ID"
|
required
|
||||||
required
|
type="number"
|
||||||
type="number"
|
value={form.targetUserId}
|
||||||
value={form.targetUserId}
|
onChange={(event) => setForm({ ...form, targetUserId: event.target.value })}
|
||||||
onChange={(event) => setForm({ ...form, targetUserId: event.target.value })}
|
/>
|
||||||
/>
|
<TextField
|
||||||
</AdminFormSection>
|
disabled={disabled}
|
||||||
<AdminFormSection title="赠送内容">
|
label="对象类型"
|
||||||
<AdminFormFieldGrid>
|
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" ? (
|
||||||
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled || optionsLoading}
|
||||||
label="对象类型"
|
label="资源"
|
||||||
required
|
required
|
||||||
select
|
select
|
||||||
value={form.subjectType}
|
value={form.resourceId}
|
||||||
onChange={(event) =>
|
onChange={(event) => setForm({ ...form, resourceId: event.target.value })}
|
||||||
setForm({ ...form, groupId: "", resourceId: "", subjectType: event.target.value })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<MenuItem value="resource">资源</MenuItem>
|
{resourceOptions.map((resource) => (
|
||||||
<MenuItem value="resource_group">资源组</MenuItem>
|
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
||||||
|
{resource.name || resource.resourceCode || resource.resourceId}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
{form.subjectType === "resource" ? (
|
<TextField
|
||||||
<>
|
disabled={disabled}
|
||||||
<TextField
|
label="数量"
|
||||||
disabled={disabled || optionsLoading}
|
required
|
||||||
label="资源"
|
type="number"
|
||||||
required
|
value={form.quantity}
|
||||||
select
|
onChange={(event) => setForm({ ...form, quantity: event.target.value })}
|
||||||
value={form.resourceId}
|
/>
|
||||||
onChange={(event) => setForm({ ...form, resourceId: event.target.value })}
|
<TextField
|
||||||
>
|
disabled={disabled}
|
||||||
{resourceOptions.map((resource) => (
|
label="有效天数"
|
||||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
type="number"
|
||||||
{resource.name || resource.resourceCode || resource.resourceId}
|
value={form.durationDays}
|
||||||
</MenuItem>
|
onChange={(event) => setForm({ ...form, durationDays: event.target.value })}
|
||||||
))}
|
/>
|
||||||
</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
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled || optionsLoading}
|
||||||
label="原因"
|
label="资源组"
|
||||||
multiline
|
|
||||||
minRows={2}
|
|
||||||
required
|
required
|
||||||
value={form.reason}
|
select
|
||||||
onChange={(event) => setForm({ ...form, reason: event.target.value })}
|
value={form.groupId}
|
||||||
/>
|
onChange={(event) => setForm({ ...form, groupId: event.target.value })}
|
||||||
</AdminFormSection>
|
>
|
||||||
|
{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 })}
|
||||||
|
/>
|
||||||
</AdminFormDialog>
|
</AdminFormDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -293,49 +279,6 @@ 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) {
|
function formatDuration(durationMs) {
|
||||||
const days = Number(durationMs || 0) / (24 * 60 * 60 * 1000);
|
const days = Number(durationMs || 0) / (24 * 60 * 60 * 1000);
|
||||||
return Number.isInteger(days) ? `${days}天` : `${days.toFixed(1)}天`;
|
return Number.isInteger(days) ? `${days}天` : `${days.toFixed(1)}天`;
|
||||||
|
|||||||
@ -5,8 +5,9 @@ import DiamondOutlined from "@mui/icons-material/DiamondOutlined";
|
|||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import Switch from "@mui/material/Switch";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import {
|
import {
|
||||||
AdminFormDialog,
|
AdminFormDialog,
|
||||||
@ -15,18 +16,19 @@ import {
|
|||||||
AdminFormList,
|
AdminFormList,
|
||||||
AdminFormListRow,
|
AdminFormListRow,
|
||||||
AdminFormSection,
|
AdminFormSection,
|
||||||
AdminFormSwitchField,
|
|
||||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
|
AdminFilterResetButton,
|
||||||
AdminListBody,
|
AdminListBody,
|
||||||
AdminListPage,
|
AdminListPage,
|
||||||
AdminListToolbar,
|
AdminListToolbar,
|
||||||
AdminRowActions,
|
AdminRowActions,
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { resourceGroupAssetLabels, resourceStatusFilters, resourceTypeLabels } from "@/features/resources/constants.js";
|
import { resourceGroupAssetLabels, resourceStatusFilters, resourceTypeLabels } from "@/features/resources/constants.js";
|
||||||
@ -108,6 +110,9 @@ export function ResourceGroupListPage() {
|
|||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<AdminFilterResetButton disabled={!page.query && !page.status} onClick={page.resetFilters} />
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
page.abilities.canCreateGroup ? (
|
page.abilities.canCreateGroup ? (
|
||||||
<AdminActionIconButton label="添加资源组" primary onClick={page.openCreateGroup}>
|
<AdminActionIconButton label="添加资源组" primary onClick={page.openCreateGroup}>
|
||||||
@ -118,22 +123,15 @@ export function ResourceGroupListPage() {
|
|||||||
/>
|
/>
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
<DataTable
|
<DataTable columns={columns} items={items} minWidth="1060px" rowKey={(group) => group.groupId} />
|
||||||
columns={columns}
|
{total > 0 ? (
|
||||||
items={items}
|
<PaginationBar
|
||||||
minWidth="1060px"
|
page={page.page}
|
||||||
pagination={
|
pageSize={page.data.pageSize || 10}
|
||||||
total > 0
|
total={total}
|
||||||
? {
|
onPageChange={page.setPage}
|
||||||
page: page.page,
|
/>
|
||||||
pageSize: page.data.pageSize || 10,
|
) : null}
|
||||||
total,
|
|
||||||
onPageChange: page.setPage,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rowKey={(group) => group.groupId}
|
|
||||||
/>
|
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
<ResourceGroupFormDialog
|
<ResourceGroupFormDialog
|
||||||
@ -173,7 +171,7 @@ function ResourceGroupRowActions({ group, page }) {
|
|||||||
function ResourceGroupStatusSwitch({ group, page }) {
|
function ResourceGroupStatusSwitch({ group, page }) {
|
||||||
const checked = group.status === "active";
|
const checked = group.status === "active";
|
||||||
return (
|
return (
|
||||||
<AdminSwitch
|
<Switch
|
||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={!page.abilities.canUpdateGroup || page.loadingAction === `group-status-${group.groupId}`}
|
disabled={!page.abilities.canUpdateGroup || page.loadingAction === `group-status-${group.groupId}`}
|
||||||
inputProps={{ "aria-label": checked ? "禁用资源组" : "启用资源组" }}
|
inputProps={{ "aria-label": checked ? "禁用资源组" : "启用资源组" }}
|
||||||
@ -194,67 +192,62 @@ function ResourceGroupFormDialog({ disabled, form, loading, mode, onClose, onSub
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
<AdminFormSection title="基础信息">
|
<AdminFormFieldGrid columns="minmax(210px, 1fr) minmax(210px, 1fr) 112px">
|
||||||
<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 })}
|
|
||||||
/>
|
|
||||||
<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
|
<TextField
|
||||||
disabled={disabled}
|
label="资源组名称"
|
||||||
label="说明"
|
required
|
||||||
multiline
|
value={form.name}
|
||||||
minRows={2}
|
onChange={(event) => page.setForm({ ...form, name: event.target.value })}
|
||||||
value={form.description}
|
|
||||||
onChange={(event) => page.setForm({ ...form, description: event.target.value })}
|
|
||||||
/>
|
/>
|
||||||
<AdminFormSwitchField
|
<TextField
|
||||||
checked={form.enabled}
|
label="资源组编码"
|
||||||
checkedLabel="启用"
|
required
|
||||||
disabled={disabled}
|
value={form.groupCode}
|
||||||
label="资源组状态"
|
onChange={(event) => page.setForm({ ...form, groupCode: event.target.value })}
|
||||||
uncheckedLabel="禁用"
|
|
||||||
onChange={(checked) => page.setForm({ ...form, enabled: checked })}
|
|
||||||
/>
|
/>
|
||||||
</AdminFormSection>
|
<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 })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={form.enabled ? "启用" : "禁用"}
|
||||||
|
/>
|
||||||
<AdminFormSection
|
<AdminFormSection
|
||||||
title="组内资源"
|
title="组内资源"
|
||||||
actions={
|
actions={
|
||||||
<AdminFormInlineActions>
|
<AdminFormInlineActions>
|
||||||
<Button
|
<Button
|
||||||
disabled={disabled || loading}
|
disabled={loading}
|
||||||
onClick={page.addResourceItem}
|
onClick={page.addResourceItem}
|
||||||
startIcon={<Inventory2Outlined fontSize="small" />}
|
startIcon={<Inventory2Outlined fontSize="small" />}
|
||||||
>
|
>
|
||||||
资源
|
资源
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={disabled || loading}
|
disabled={loading}
|
||||||
onClick={() => page.addWalletAssetItem("COIN")}
|
onClick={() => page.addWalletAssetItem("COIN")}
|
||||||
startIcon={<MonetizationOnOutlined fontSize="small" />}
|
startIcon={<MonetizationOnOutlined fontSize="small" />}
|
||||||
>
|
>
|
||||||
金币
|
金币
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={disabled || loading}
|
disabled={loading}
|
||||||
onClick={() => page.addWalletAssetItem("DIAMOND")}
|
onClick={() => page.addWalletAssetItem("DIAMOND")}
|
||||||
startIcon={<DiamondOutlined fontSize="small" />}
|
startIcon={<DiamondOutlined fontSize="small" />}
|
||||||
>
|
>
|
||||||
@ -266,7 +259,7 @@ function ResourceGroupFormDialog({ disabled, form, loading, mode, onClose, onSub
|
|||||||
<AdminFormList>
|
<AdminFormList>
|
||||||
{form.items.map((item, index) => (
|
{form.items.map((item, index) => (
|
||||||
<ResourceGroupItemEditor
|
<ResourceGroupItemEditor
|
||||||
disabled={disabled || loading}
|
disabled={loading}
|
||||||
index={index}
|
index={index}
|
||||||
item={item}
|
item={item}
|
||||||
key={`${item.itemType}-${item.walletAssetType || item.resourceId || index}-${index}`}
|
key={`${item.itemType}-${item.walletAssetType || item.resourceId || index}-${index}`}
|
||||||
|
|||||||
@ -1,25 +1,23 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import Switch from "@mui/material/Switch";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import {
|
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
AdminFormDialog,
|
|
||||||
AdminFormFieldGrid,
|
|
||||||
AdminFormSection,
|
|
||||||
AdminFormSwitchField,
|
|
||||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
|
AdminFilterResetButton,
|
||||||
AdminListBody,
|
AdminListBody,
|
||||||
AdminListPage,
|
AdminListPage,
|
||||||
AdminRowActions,
|
AdminRowActions,
|
||||||
AdminListToolbar,
|
AdminListToolbar,
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import {
|
import {
|
||||||
@ -123,6 +121,12 @@ export function ResourceListPage() {
|
|||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<AdminFilterResetButton
|
||||||
|
disabled={!page.query && !page.status && !page.resourceType}
|
||||||
|
onClick={page.resetFilters}
|
||||||
|
/>
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
page.abilities.canCreate ? (
|
page.abilities.canCreate ? (
|
||||||
<AdminActionIconButton label="添加资源" primary onClick={page.openCreateResource}>
|
<AdminActionIconButton label="添加资源" primary onClick={page.openCreateResource}>
|
||||||
@ -137,18 +141,16 @@ export function ResourceListPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1120px"
|
minWidth="1120px"
|
||||||
pagination={
|
|
||||||
total > 0
|
|
||||||
? {
|
|
||||||
page: page.page,
|
|
||||||
pageSize: page.data.pageSize || 10,
|
|
||||||
total,
|
|
||||||
onPageChange: page.setPage,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rowKey={(resource) => resource.resourceId}
|
rowKey={(resource) => resource.resourceId}
|
||||||
/>
|
/>
|
||||||
|
{total > 0 ? (
|
||||||
|
<PaginationBar
|
||||||
|
page={page.page}
|
||||||
|
pageSize={page.data.pageSize || 10}
|
||||||
|
total={total}
|
||||||
|
onPageChange={page.setPage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
<ResourceFormDialog
|
<ResourceFormDialog
|
||||||
@ -195,74 +197,61 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
<AdminFormSection title="基础信息">
|
<TextField
|
||||||
<AdminFormFieldGrid>
|
label="资源名称"
|
||||||
<TextField
|
required
|
||||||
disabled={disabled}
|
value={form.name}
|
||||||
label="资源名称"
|
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||||
required
|
/>
|
||||||
value={form.name}
|
<TextField
|
||||||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
label="资源编码"
|
||||||
/>
|
required
|
||||||
<TextField
|
value={form.resourceCode}
|
||||||
disabled={disabled}
|
onChange={(event) => setForm({ ...form, resourceCode: event.target.value })}
|
||||||
label="资源编码"
|
/>
|
||||||
required
|
<TextField
|
||||||
value={form.resourceCode}
|
label="资源类型"
|
||||||
onChange={(event) => setForm({ ...form, resourceCode: event.target.value })}
|
required
|
||||||
/>
|
select
|
||||||
<TextField
|
value={form.resourceType}
|
||||||
disabled={disabled}
|
onChange={(event) => setForm({ ...form, resourceType: event.target.value, walletAssetAmount: "" })}
|
||||||
label="资源类型"
|
>
|
||||||
required
|
{resourceTypeOptions.map(([value, label]) => (
|
||||||
select
|
<MenuItem key={value} value={value}>
|
||||||
value={form.resourceType}
|
{label}
|
||||||
onChange={(event) => setForm({ ...form, resourceType: event.target.value, walletAssetAmount: "" })}
|
</MenuItem>
|
||||||
>
|
))}
|
||||||
{resourceTypeOptions.map(([value, label]) => (
|
</TextField>
|
||||||
<MenuItem key={value} value={value}>
|
{form.resourceType === "coin" ? (
|
||||||
{label}
|
<TextField
|
||||||
</MenuItem>
|
label="金币数量"
|
||||||
))}
|
required
|
||||||
</TextField>
|
type="number"
|
||||||
{form.resourceType === "coin" ? (
|
value={form.walletAssetAmount}
|
||||||
<TextField
|
onChange={(event) => setForm({ ...form, walletAssetAmount: event.target.value })}
|
||||||
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>
|
) : 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 })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={form.enabled ? "启用" : "禁用"}
|
||||||
|
/>
|
||||||
</AdminFormDialog>
|
</AdminFormDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -300,7 +289,7 @@ function GrantInfo({ resource }) {
|
|||||||
function ResourceStatusSwitch({ page, resource }) {
|
function ResourceStatusSwitch({ page, resource }) {
|
||||||
const checked = resource.status === "active";
|
const checked = resource.status === "active";
|
||||||
return (
|
return (
|
||||||
<AdminSwitch
|
<Switch
|
||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={!page.abilities.canUpdate || page.loadingAction === `resource-status-${resource.resourceId}`}
|
disabled={!page.abilities.canUpdate || page.loadingAction === `resource-status-${resource.resourceId}`}
|
||||||
inputProps={{ "aria-label": checked ? "禁用资源" : "启用资源" }}
|
inputProps={{ "aria-label": checked ? "禁用资源" : "启用资源" }}
|
||||||
|
|||||||
@ -25,26 +25,6 @@
|
|||||||
object-fit: cover;
|
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,
|
.identityText,
|
||||||
.stack {
|
.stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const resourceTypes = ["avatar_frame", "profile_card", "coin", "vehicle", "chat_bubble", "badge", "floating_screen", "gift"];
|
const resourceTypes = ["avatar_frame", "coin", "vehicle", "chat_bubble", "badge", "floating_screen", "gift"];
|
||||||
const walletAssetTypes = ["COIN", "DIAMOND"];
|
const walletAssetTypes = ["COIN", "DIAMOND"];
|
||||||
const optionalWalletAssetTypeSchema = z
|
const optionalWalletAssetTypeSchema = z
|
||||||
.preprocess((value) => {
|
.preprocess((value) => {
|
||||||
|
|||||||
@ -1,22 +1,8 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
||||||
import { giftFormSchema, resourceFormSchema, resourceGrantFormSchema, resourceGroupCreateFormSchema } from "./schema.js";
|
import { giftFormSchema, resourceGrantFormSchema, resourceGroupCreateFormSchema } from "./schema.js";
|
||||||
|
|
||||||
describe("resource form schema", () => {
|
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", () => {
|
test("allows resource group resource items without wallet asset type", () => {
|
||||||
const payload = parseForm(resourceGroupCreateFormSchema, {
|
const payload = parseForm(resourceGroupCreateFormSchema, {
|
||||||
description: "",
|
description: "",
|
||||||
|
|||||||
@ -19,30 +19,22 @@ export function RoleFormDrawer({
|
|||||||
<Drawer anchor="right" open={open} onClose={onClose}>
|
<Drawer anchor="right" open={open} onClose={onClose}>
|
||||||
<form className="form-drawer form-drawer--wide" onSubmit={onSubmit}>
|
<form className="form-drawer form-drawer--wide" onSubmit={onSubmit}>
|
||||||
<h2>{editingRole ? "编辑角色" : "新增角色"}</h2>
|
<h2>{editingRole ? "编辑角色" : "新增角色"}</h2>
|
||||||
<section className="form-drawer__section">
|
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="角色名称" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
||||||
<div className="form-drawer__section-title">基础信息</div>
|
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="角色编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
|
||||||
<div className="form-drawer__grid">
|
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="说明" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
|
||||||
<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 ? (
|
{abilities.canLoadPermissions ? (
|
||||||
<section className="form-drawer__section">
|
<div className="permission-menu-list">
|
||||||
<div className="form-drawer__section-title">权限范围</div>
|
{permissionGroups.map((group) => (
|
||||||
<div className="permission-menu-list">
|
<PermissionMenuGroup
|
||||||
{permissionGroups.map((group) => (
|
abilities={abilities}
|
||||||
<PermissionMenuGroup
|
group={group}
|
||||||
abilities={abilities}
|
key={group.key}
|
||||||
group={group}
|
permissionIds={form.permissionIds}
|
||||||
key={group.key}
|
onTogglePermission={onTogglePermission}
|
||||||
permissionIds={form.permissionIds}
|
onTogglePermissionGroup={onTogglePermissionGroup}
|
||||||
onTogglePermission={onTogglePermission}
|
/>
|
||||||
onTogglePermissionGroup={onTogglePermissionGroup}
|
))}
|
||||||
/>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
) : null}
|
||||||
<div className="form-drawer__actions">
|
<div className="form-drawer__actions">
|
||||||
<Button onClick={onClose}>取消</Button>
|
<Button onClick={onClose}>取消</Button>
|
||||||
|
|||||||
@ -25,11 +25,11 @@ const permissionGroupDefinitions = [
|
|||||||
{ key: "rooms", prefixes: ["room"], title: "房间管理" },
|
{ key: "rooms", prefixes: ["room"], title: "房间管理" },
|
||||||
{ key: "countries", prefixes: ["country"], title: "国家管理" },
|
{ key: "countries", prefixes: ["country"], title: "国家管理" },
|
||||||
{ key: "regions", prefixes: ["region"], title: "区域管理" },
|
{ key: "regions", prefixes: ["region"], title: "区域管理" },
|
||||||
{ key: "agencies", prefixes: ["agency"], title: "Agency 列表" },
|
{ key: "agencies", prefixes: ["agency"], title: "Agency 管理" },
|
||||||
{ key: "bds", prefixes: ["bd"], title: "BD 列表" },
|
{ key: "bds", prefixes: ["bd"], title: "BD 管理" },
|
||||||
{ key: "hosts", prefixes: ["host"], title: "主播列表" },
|
{ key: "hosts", prefixes: ["host"], title: "主播管理" },
|
||||||
{ key: "coin-sellers", prefixes: ["coin-seller"], title: "币商列表" },
|
|
||||||
{ key: "logs", prefixes: ["log"], title: "日志审计" },
|
{ key: "logs", prefixes: ["log"], title: "日志审计" },
|
||||||
|
{ key: "notifications", prefixes: ["notification"], title: "通知中心" },
|
||||||
{ key: "jobs", prefixes: ["job", "export"], title: "任务中心" },
|
{ key: "jobs", prefixes: ["job", "export"], title: "任务中心" },
|
||||||
{ key: "common", prefixes: ["upload"], title: "通用能力" },
|
{ key: "common", prefixes: ["upload"], title: "通用能力" },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import Add from "@mui/icons-material/Add";
|
|||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
|
AdminFilterResetButton,
|
||||||
AdminListBody,
|
AdminListBody,
|
||||||
AdminListPage,
|
AdminListPage,
|
||||||
AdminListToolbar,
|
AdminListToolbar,
|
||||||
@ -17,6 +18,9 @@ export function RoleManagementPage() {
|
|||||||
<>
|
<>
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<AdminFilterResetButton disabled={!page.query} onClick={page.resetFilters} />
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
page.abilities.canCreate ? (
|
page.abilities.canCreate ? (
|
||||||
<AdminActionIconButton label="新增角色" primary onClick={page.openCreate}>
|
<AdminActionIconButton label="新增角色" primary onClick={page.openCreate}>
|
||||||
|
|||||||
@ -4,19 +4,17 @@ import type {
|
|||||||
ApiPage,
|
ApiPage,
|
||||||
EntityId,
|
EntityId,
|
||||||
PageQuery,
|
PageQuery,
|
||||||
RoomConfigDto,
|
|
||||||
RoomConfigPayload,
|
|
||||||
RoomDto,
|
RoomDto,
|
||||||
RoomPinDto,
|
RoomPinDto,
|
||||||
RoomPinPayload,
|
RoomPinPayload,
|
||||||
RoomUpdatePayload,
|
RoomUpdatePayload
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
|
|
||||||
export function listRooms(query: PageQuery = {}): Promise<ApiPage<RoomDto>> {
|
export function listRooms(query: PageQuery = {}): Promise<ApiPage<RoomDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listRooms;
|
const endpoint = API_ENDPOINTS.listRooms;
|
||||||
return apiRequest<ApiPage<RoomDto>>(apiEndpointPath(API_OPERATIONS.listRooms), {
|
return apiRequest<ApiPage<RoomDto>>(apiEndpointPath(API_OPERATIONS.listRooms), {
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
query,
|
query
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,7 +22,7 @@ export function updateRoom(roomId: EntityId, payload: RoomUpdatePayload): Promis
|
|||||||
const endpoint = API_ENDPOINTS.updateRoom;
|
const endpoint = API_ENDPOINTS.updateRoom;
|
||||||
return apiRequest<RoomDto, RoomUpdatePayload>(apiEndpointPath(API_OPERATIONS.updateRoom, { room_id: roomId }), {
|
return apiRequest<RoomDto, RoomUpdatePayload>(apiEndpointPath(API_OPERATIONS.updateRoom, { room_id: roomId }), {
|
||||||
body: payload,
|
body: payload,
|
||||||
method: endpoint.method,
|
method: endpoint.method
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,21 +52,6 @@ export function cancelRoomPin(pinId: EntityId): Promise<RoomPinDto> {
|
|||||||
export function deleteRoom(roomId: EntityId): Promise<{ deleted: boolean }> {
|
export function deleteRoom(roomId: EntityId): Promise<{ deleted: boolean }> {
|
||||||
const endpoint = API_ENDPOINTS.deleteRoom;
|
const endpoint = API_ENDPOINTS.deleteRoom;
|
||||||
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteRoom, { room_id: roomId }), {
|
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,7 +1,5 @@
|
|||||||
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
|
|
||||||
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
|
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
|
||||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { roomStatusLabels } from "@/features/rooms/constants.js";
|
import { roomStatusLabels } from "@/features/rooms/constants.js";
|
||||||
@ -10,7 +8,7 @@ import styles from "@/features/rooms/rooms.module.css";
|
|||||||
export function RoomDetailDrawer({ onClose, open, room }) {
|
export function RoomDetailDrawer({ onClose, open, room }) {
|
||||||
const owner = room?.owner || {};
|
const owner = room?.owner || {};
|
||||||
const normalizedStatus = room?.status === "active" ? "active" : "closed";
|
const normalizedStatus = room?.status === "active" ? "active" : "closed";
|
||||||
const ownerDisplayId = ownerLongShortId(owner, room);
|
const ownerDisplayId = owner.displayUserId || room?.ownerUserId || "-";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SideDrawer open={open} title="房间详情" width="wide" onClose={onClose}>
|
<SideDrawer open={open} title="房间详情" width="wide" onClose={onClose}>
|
||||||
@ -25,17 +23,18 @@ export function RoomDetailDrawer({ onClose, open, room }) {
|
|||||||
<h3>{room.title || "-"}</h3>
|
<h3>{room.title || "-"}</h3>
|
||||||
<StatusBadge status={normalizedStatus} />
|
<StatusBadge status={normalizedStatus} />
|
||||||
</div>
|
</div>
|
||||||
<CopyableRoomId className={styles.detailMeta} prefix="房间 ID " roomId={room.roomId} />
|
<span className={styles.detailMeta}>房间 ID {room.roomId}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<DetailSection
|
<DetailSection
|
||||||
items={[
|
items={[
|
||||||
["房间名称", room.title],
|
["房间名称", room.title],
|
||||||
["房间 ID", <CopyableRoomId key="room-id" roomId={room.roomId} />],
|
["房间 ID", room.roomId],
|
||||||
["房间状态", roomStatusLabels[normalizedStatus]],
|
["房间状态", roomStatusLabels[normalizedStatus]],
|
||||||
["房间模式", room.mode],
|
["房间模式", room.mode],
|
||||||
["区域", room.regionName],
|
["区域", room.regionName || formatRegion(room.visibleRegionId)],
|
||||||
|
["可见区域 ID", room.visibleRegionId],
|
||||||
["创建时间", formatMillis(room.createdAtMs)],
|
["创建时间", formatMillis(room.createdAtMs)],
|
||||||
["更新时间", formatMillis(room.updatedAtMs)]
|
["更新时间", formatMillis(room.updatedAtMs)]
|
||||||
]}
|
]}
|
||||||
@ -54,7 +53,8 @@ export function RoomDetailDrawer({ onClose, open, room }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailGrid}>
|
<div className={styles.detailGrid}>
|
||||||
<DetailItem label="房主长 ID(短 ID)" value={ownerDisplayId} />
|
<DetailItem label="房主用户 ID" value={room.ownerUserId || owner.userId} />
|
||||||
|
<DetailItem label="主持用户 ID" value={room.hostUserId} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -63,7 +63,6 @@ export function RoomDetailDrawer({ onClose, open, room }) {
|
|||||||
["在线人数", `${formatNumber(room.onlineCount)} 人`],
|
["在线人数", `${formatNumber(room.onlineCount)} 人`],
|
||||||
["座位数", `${formatNumber(room.seatCount)} 个`],
|
["座位数", `${formatNumber(room.seatCount)} 个`],
|
||||||
["已占座位", `${formatNumber(room.occupiedSeatCount)} 个`],
|
["已占座位", `${formatNumber(room.occupiedSeatCount)} 个`],
|
||||||
["房间贡献", formatNumber(roomContributionValue(room))],
|
|
||||||
["热度", formatNumber(room.heat)],
|
["热度", formatNumber(room.heat)],
|
||||||
["排序分", formatNumber(room.sortScore)]
|
["排序分", formatNumber(room.sortScore)]
|
||||||
]}
|
]}
|
||||||
@ -92,46 +91,11 @@ function DetailItem({ label, value }) {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.detailItem}>
|
<div className={styles.detailItem}>
|
||||||
<span className={styles.detailLabel}>{label}</span>
|
<span className={styles.detailLabel}>{label}</span>
|
||||||
<div className={styles.detailValue}>{displayValue(value)}</div>
|
<strong className={styles.detailValue}>{displayValue(value)}</strong>
|
||||||
</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 }) {
|
function StatusBadge({ status }) {
|
||||||
const tone = status === "active" ? "running" : "stopped";
|
const tone = status === "active" ? "running" : "stopped";
|
||||||
|
|
||||||
@ -151,36 +115,6 @@ function formatNumber(value) {
|
|||||||
return Number(value || 0).toLocaleString("zh-CN");
|
return Number(value || 0).toLocaleString("zh-CN");
|
||||||
}
|
}
|
||||||
|
|
||||||
function roomContributionValue(room) {
|
function formatRegion(value) {
|
||||||
return room?.roomContribution ?? room?.heat ?? 0;
|
return value ? `区域 ${value}` : "-";
|
||||||
}
|
|
||||||
|
|
||||||
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 || "-";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,109 +0,0 @@
|
|||||||
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) : [];
|
|
||||||
}
|
|
||||||
@ -26,7 +26,6 @@ export function useRoomsPage() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [regionId, setRegionId] = useState("");
|
const [regionId, setRegionId] = useState("");
|
||||||
const [contributionSortDirection, setContributionSortDirection] = useState("");
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [activeAction, setActiveAction] = useState("");
|
const [activeAction, setActiveAction] = useState("");
|
||||||
const [activeRoom, setActiveRoom] = useState(null);
|
const [activeRoom, setActiveRoom] = useState(null);
|
||||||
@ -37,11 +36,9 @@ export function useRoomsPage() {
|
|||||||
() => ({
|
() => ({
|
||||||
keyword: query,
|
keyword: query,
|
||||||
region_id: regionId,
|
region_id: regionId,
|
||||||
sort_by: contributionSortDirection ? "room_contribution" : "",
|
|
||||||
sort_direction: contributionSortDirection,
|
|
||||||
status,
|
status,
|
||||||
}),
|
}),
|
||||||
[contributionSortDirection, query, regionId, status],
|
[query, regionId, status],
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
data = emptyData,
|
data = emptyData,
|
||||||
@ -72,11 +69,6 @@ export function useRoomsPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleContributionSort = () => {
|
|
||||||
setContributionSortDirection((current) => (current === "desc" ? "asc" : "desc"));
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setStatus("");
|
setStatus("");
|
||||||
@ -176,7 +168,6 @@ export function useRoomsPage() {
|
|||||||
changeRegionId,
|
changeRegionId,
|
||||||
changeStatus,
|
changeStatus,
|
||||||
closeAction,
|
closeAction,
|
||||||
contributionSortDirection,
|
|
||||||
data,
|
data,
|
||||||
error,
|
error,
|
||||||
form,
|
form,
|
||||||
@ -200,6 +191,5 @@ export function useRoomsPage() {
|
|||||||
statusForm,
|
statusForm,
|
||||||
submitStatus,
|
submitStatus,
|
||||||
submitEdit,
|
submitEdit,
|
||||||
toggleContributionSort,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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