优化布局

This commit is contained in:
zhx 2026-05-14 09:57:41 +08:00
parent dd8cd5d952
commit 4dad53162e
130 changed files with 8545 additions and 2115 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
dist
.git
.DS_Store
coverage
run
*.log

View File

@ -89,6 +89,7 @@ import { Refresh } from "@mui/icons-material";
- 表格空态只显示 `当前无数据`,创建入口放在页面工具栏,不放在空态里。
- 表格里的启用/禁用状态优先使用行内 Switch 快捷操作,不使用单独的启用/禁用图标按钮。
- 表格状态筛选使用 Select 下拉框,默认值为 `全部状态`,不使用状态 chip 组。
- 后台列表页的时间区间筛选必须使用统一的单入口时间区间筛选框(`src/shared/ui/TimeRangeFilter.jsx`);不要在工具栏里并排放两个开始/结束时间输入框,也不要使用浏览器原生日期/时间输入样式。时间区间弹层内使用快捷项、日历和时分下拉点选,不使用手写时间文本框。
- 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。
- 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。
- 图片上传表单必须提供统一预览;`webp` 使用浏览器原生动效预览,`svga` 使用 SVGA 播放器预览,`pag` 使用 libpag + wasm 预览。

22
Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM node:24-alpine AS builder
WORKDIR /src
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN pnpm build
FROM nginx:1.27-alpine
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /src/dist /usr/share/nginx/html
EXPOSE 80

View File

@ -84,6 +84,46 @@
]
}
},
"/admin/activity/registration-reward/claims": {
"get": {
"operationId": "listRegistrationRewardClaims",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "registration-reward:view",
"x-permissions": [
"registration-reward:view"
]
}
},
"/admin/activity/registration-reward/config": {
"get": {
"operationId": "getRegistrationRewardConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "registration-reward:view",
"x-permissions": [
"registration-reward:view"
]
},
"put": {
"operationId": "updateRegistrationRewardConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "registration-reward:update",
"x-permissions": [
"registration-reward:update"
]
}
},
"/admin/agencies": {
"get": {
"operationId": "listAgencies",
@ -276,6 +316,78 @@
]
}
},
"/admin/app-config/versions": {
"get": {
"operationId": "listAppVersions",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "app-version:view",
"x-permissions": [
"app-version:view"
]
},
"post": {
"operationId": "createAppVersion",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "app-version:create",
"x-permissions": [
"app-version:create"
]
}
},
"/admin/app-config/versions/{version_id}": {
"put": {
"operationId": "updateAppVersion",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "version_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "app-version:update",
"x-permissions": [
"app-version:update"
]
},
"delete": {
"operationId": "deleteAppVersion",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "version_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "app-version:delete",
"x-permissions": [
"app-version:delete"
]
}
},
"/admin/apps": {
"get": {
"operationId": "listApps",
@ -691,6 +803,130 @@
]
}
},
"/admin/game/games": {
"get": {
"operationId": "listCatalog",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "game:view",
"x-permissions": [
"game:view"
]
},
"post": {
"operationId": "createCatalog",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "game:create",
"x-permissions": [
"game:create"
]
}
},
"/admin/game/games/{game_id}": {
"patch": {
"operationId": "updateCatalog",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "game_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "game:update",
"x-permissions": [
"game:update"
]
}
},
"/admin/game/games/{game_id}/status": {
"patch": {
"operationId": "setGameStatus",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "game_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "game:status",
"x-permissions": [
"game:status"
]
}
},
"/admin/game/platforms": {
"get": {
"operationId": "listPlatforms",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "game:view",
"x-permissions": [
"game:view"
]
},
"post": {
"operationId": "createPlatform",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "game:update",
"x-permissions": [
"game:update"
]
}
},
"/admin/game/platforms/{platform_code}": {
"patch": {
"operationId": "updatePlatform",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "platform_code",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "game:update",
"x-permissions": [
"game:update"
]
}
},
"/admin/gifts": {
"get": {
"operationId": "listGifts",
@ -823,6 +1059,20 @@
]
}
},
"/admin/operations/coin-ledger": {
"get": {
"operationId": "listCoinLedger",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "coin-ledger:view",
"x-permissions": [
"coin-ledger:view"
]
}
},
"/admin/payment/recharge-bills": {
"get": {
"operationId": "listRechargeBills",
@ -837,6 +1087,78 @@
]
}
},
"/admin/payment/recharge-products": {
"get": {
"operationId": "listRechargeProducts",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "payment-product:view",
"x-permissions": [
"payment-product:view"
]
},
"post": {
"operationId": "createRechargeProduct",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "payment-product:create",
"x-permissions": [
"payment-product:create"
]
}
},
"/admin/payment/recharge-products/{product_id}": {
"put": {
"operationId": "updateRechargeProduct",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "product_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "payment-product:update",
"x-permissions": [
"payment-product:update"
]
},
"delete": {
"operationId": "deleteRechargeProduct",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "product_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "payment-product:delete",
"x-permissions": [
"payment-product:delete"
]
}
},
"/admin/regions": {
"get": {
"operationId": "listRegions",
@ -1345,6 +1667,32 @@
]
}
},
"/admin/rooms/config": {
"get": {
"operationId": "getRoomConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "room-config:view",
"x-permissions": [
"room-config:view"
]
},
"put": {
"operationId": "updateRoomConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "room-config:update",
"x-permissions": [
"room-config:update"
]
}
},
"/app/users": {
"get": {
"operationId": "appListUsers",
@ -1471,6 +1819,20 @@
]
}
},
"/app/users/bans": {
"get": {
"operationId": "appListBannedUsers",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "app-user:view",
"x-permissions": [
"app-user:view"
]
}
},
"/app/users/login-logs": {
"get": {
"operationId": "appListLoginLogs",
@ -1709,72 +2071,6 @@
}
}
},
"/notifications": {
"get": {
"operationId": "listNotifications",
"x-permission": "notification:view",
"responses": {
"200": {
"$ref": "#/components/responses/NotificationListResponse"
}
},
"x-permissions": [
"notification:view"
]
}
},
"/notifications/{id}": {
"delete": {
"operationId": "deleteNotification",
"x-permission": "notification:delete",
"parameters": [
{
"$ref": "#/components/parameters/Id"
}
],
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permissions": [
"notification:delete"
]
}
},
"/notifications/{id}/read": {
"patch": {
"operationId": "markNotificationRead",
"x-permission": "notification:read",
"parameters": [
{
"$ref": "#/components/parameters/Id"
}
],
"responses": {
"200": {
"$ref": "#/components/responses/NotificationResponse"
}
},
"x-permissions": [
"notification:read"
]
}
},
"/notifications/read-all": {
"patch": {
"operationId": "markAllNotificationsRead",
"x-permission": "notification:read-all",
"responses": {
"200": {
"$ref": "#/components/responses/NotificationListResponse"
}
},
"x-permissions": [
"notification:read-all"
]
}
},
"/permissions": {
"get": {
"operationId": "listPermissions",
@ -2623,26 +2919,6 @@
}
}
},
"NotificationListResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseNotificationList"
}
}
}
},
"NotificationResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseNotification"
}
}
}
},
"PermissionListResponse": {
"description": "OK",
"content": {
@ -2991,36 +3267,6 @@
}
]
},
"ApiResponseNotification": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Notification"
}
}
}
]
},
"ApiResponseNotificationList": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/NotificationList"
}
}
}
]
},
"ApiResponsePermission": {
"allOf": [
{
@ -3357,28 +3603,6 @@
],
"additionalProperties": true
},
"Notification": {
"type": "object",
"additionalProperties": true
},
"NotificationList": {
"type": "object",
"required": [
"items",
"unread"
],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Notification"
}
},
"unread": {
"type": "integer"
}
}
},
"Permission": {
"type": "object",
"additionalProperties": true

34
deploy/nginx.conf Normal file
View File

@ -0,0 +1,34 @@
server {
listen 80;
server_name hyapp-platform.global-interaction.com _;
root /usr/share/nginx/html;
index index.html;
location = /__frontend_healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
location /api/ {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:13100/api/;
}
location = /healthz {
proxy_pass http://127.0.0.1:13100/healthz;
}
location = /readyz {
proxy_pass http://127.0.0.1:13100/readyz;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@ -4,7 +4,6 @@ import LockReset from "@mui/icons-material/LockReset";
import Logout from "@mui/icons-material/Logout";
import Menu from "@mui/icons-material/Menu";
import MenuOpen from "@mui/icons-material/MenuOpen";
import NotificationsNoneOutlined from "@mui/icons-material/NotificationsNoneOutlined";
import Person from "@mui/icons-material/Person";
import Refresh from "@mui/icons-material/Refresh";
import Search from "@mui/icons-material/Search";
@ -15,10 +14,7 @@ import { lazy, Suspense, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
import { changePassword } from "@/features/auth/api";
import { listNotifications } from "@/features/notifications/api";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
@ -35,19 +31,11 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
const [passwordError, setPasswordError] = useState("");
const [changingPassword, setChangingPassword] = useState(false);
const navigate = useNavigate();
const { can, logout, user } = useAuth();
const { logout, user } = useAuth();
const appScope = useAppScope();
const { showToast } = useToast();
const ToggleIcon = isSidebarCollapsed ? MenuOpen : Menu;
const userMenuOpen = Boolean(userMenuAnchor);
const canViewNotifications = can(PERMISSIONS.notificationView);
const { data: notificationState } = useAdminQuery(() => listNotifications(), {
enabled: canViewNotifications,
initialData: { items: [], unread: 0 },
queryKey: ["notifications", "unread"],
refetchInterval: 30000
});
const unread = notificationState?.unread || 0;
useEffect(() => {
if (searchOpen) {
@ -136,10 +124,6 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
}
};
const openNotifications = () => {
navigate("/notifications");
};
const refresh = () => {
onRefresh();
showToast("已刷新当前页面", "success");
@ -191,11 +175,6 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
<IconButton label="刷新" onClick={refresh}>
<Refresh fontSize="small" />
</IconButton>
{canViewNotifications ? (
<IconButton label="通知" notice={unread ? String(unread) : undefined} onClick={openNotifications}>
<NotificationsNoneOutlined fontSize="small" />
</IconButton>
) : null}
<button className="user-pill" type="button" onClick={(event) => setUserMenuAnchor(event.currentTarget)}>
<Person fontSize="small" />
<span>{user?.name || user?.account || "admin"}</span>

View File

@ -1,5 +1,6 @@
import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined";
import BedroomParentOutlined from "@mui/icons-material/BedroomParentOutlined";
import BlockOutlined from "@mui/icons-material/BlockOutlined";
import CampaignOutlined from "@mui/icons-material/CampaignOutlined";
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
@ -13,12 +14,12 @@ import LoginOutlined from "@mui/icons-material/LoginOutlined";
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
import MapOutlined from "@mui/icons-material/MapOutlined";
import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
import NotificationsNoneOutlined from "@mui/icons-material/NotificationsNoneOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined";
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
import SendOutlined from "@mui/icons-material/SendOutlined";
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicationsOutlined";
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
import WalletOutlined from "@mui/icons-material/WalletOutlined";
import { getRouteByMenuCode } from "@/app/router/routeConfig";
@ -31,6 +32,7 @@ const menuLabelOverrides = {
const iconMap = {
apartment: ApartmentOutlined,
block: BlockOutlined,
campaign: CampaignOutlined,
category: CategoryOutlined,
dashboard: DashboardOutlined,
@ -43,13 +45,14 @@ const iconMap = {
login: LoginOutlined,
map: MapOutlined,
menu: MenuOpenOutlined,
notifications: NotificationsNoneOutlined,
operations: ReceiptLongOutlined,
public: PublicOutlined,
receipt: ReceiptLongOutlined,
room: BedroomParentOutlined,
send: SendOutlined,
settings: SettingsApplicationsOutlined,
shield: ShieldOutlined,
sports_esports: SportsEsportsOutlined,
task: TaskAltOutlined,
users: ManageAccountsOutlined,
wallet: WalletOutlined,
@ -77,6 +80,7 @@ export const fallbackNavigation = [
label: "用户管理",
children: [
routeNavItem("app-user-list", { icon: ManageAccountsOutlined }),
routeNavItem("app-user-bans", { icon: BlockOutlined }),
routeNavItem("app-user-login-logs", { icon: LoginOutlined }),
],
},
@ -95,6 +99,8 @@ export const fallbackNavigation = [
children: [
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
routeNavItem("app-config-banners", { icon: ImageOutlined }),
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
],
},
{
@ -109,12 +115,22 @@ export const fallbackNavigation = [
routeNavItem("resource-grant-list", { icon: SendOutlined }),
],
},
{
code: "operations",
icon: ReceiptLongOutlined,
id: "operations",
label: "运营管理",
children: [routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined })],
},
{
code: "activities",
icon: CampaignOutlined,
id: "activities",
label: "活动管理",
children: [routeNavItem("daily-task-list", { icon: TaskAltOutlined })],
children: [
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
],
},
{
code: "payment",
@ -123,6 +139,13 @@ export const fallbackNavigation = [
label: "支付管理",
children: [routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })],
},
{
code: "games",
icon: SportsEsportsOutlined,
id: "games",
label: "游戏管理",
children: [routeNavItem("game-list", { icon: SportsEsportsOutlined })],
},
{
code: "geo",
icon: MapOutlined,
@ -139,6 +162,7 @@ export const fallbackNavigation = [
id: "host-org",
label: "团队管理",
children: [
routeNavItem("host-org-managers", { icon: ManageAccountsOutlined }),
routeNavItem("host-org-agencies", { icon: ApartmentOutlined }),
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
@ -146,11 +170,10 @@ export const fallbackNavigation = [
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
],
},
routeNavItem("notifications", { icon: NotificationsNoneOutlined }),
];
export function mapBackendMenus(menus = []) {
return relocateLogMenusToSystem(menus)
return relocateBackendMenus(menus)
.map((item) => {
if (deprecatedMenuCodes.has(item.code)) {
return null;
@ -172,6 +195,38 @@ export function mapBackendMenus(menus = []) {
.filter(Boolean);
}
function relocateBackendMenus(menus = []) {
return relocateRechargeProductsToAppConfig(relocateLogMenusToSystem(menus));
}
function relocateRechargeProductsToAppConfig(menus = []) {
const appConfigMenu = menus.find((item) => item.code === "app-config");
const paymentMenu = menus.find((item) => item.code === "payment");
const rechargeProductMenu = paymentMenu?.children?.find((item) => item.code === "payment-recharge-products");
if (!appConfigMenu || !rechargeProductMenu) {
return menus;
}
return menus
.map((item) => {
if (item.code === "app-config") {
const children = item.children || [];
if (children.some((child) => child.code === "payment-recharge-products")) {
return item;
}
return { ...item, children: [...children, rechargeProductMenu] };
}
if (item.code === "payment") {
return {
...item,
children: (item.children || []).filter((child) => child.code !== "payment-recharge-products"),
};
}
return item;
})
.filter((item) => item.code !== "payment" || item.path || item.children?.length);
}
function relocateLogMenusToSystem(menus = []) {
const systemMenu = menus.find((item) => item.code === "system");
const logsMenu = menus.find((item) => item.code === "logs");

View File

@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { fallbackNavigation, filterNavigationByPermission, mergeNavigationItems } from "./menu.js";
import { fallbackNavigation, filterNavigationByPermission, mapBackendMenus, mergeNavigationItems } from "./menu.js";
describe("navigation menu helpers", () => {
test("keeps only fallback menu items allowed by permissions", () => {
@ -10,6 +10,7 @@ describe("navigation menu helpers", () => {
expect(flattenCodes(items)).not.toContain("geo");
expect(flattenCodes(items)).toContain("host-org");
expect(flattenCodes(items)).toContain("host-org-managers");
expect(flattenCodes(items)).toContain("host-org-agencies");
expect(flattenCodes(items)).toContain("host-org-hosts");
expect(flattenCodes(items)).not.toContain("host-org-bds");
@ -51,6 +52,37 @@ describe("navigation menu helpers", () => {
const merged = mergeNavigationItems(backendMenus, fallbackMenus);
expect(flattenCodes(merged)).toContain("host-org-agencies");
expect(flattenCodes(merged)).toContain("host-org-managers");
});
test("places recharge products under app config even when backend menu is stale", () => {
const mapped = mapBackendMenus([
{ children: [], code: "app-config", icon: "settings", id: "app-config", label: "APP配置" },
{
children: [
{
code: "payment-recharge-products",
icon: "wallet",
id: "payment-recharge-products",
label: "内购配置",
path: "/payment/recharge-products",
permissionCode: "payment-product:view",
},
],
code: "payment",
icon: "wallet",
id: "payment",
label: "支付管理",
},
]);
const appConfig = mapped.find((item) => item.code === "app-config");
const payment = mapped.find((item) => item.code === "payment");
expect(appConfig.children.map((item) => item.code)).toContain("payment-recharge-products");
expect(appConfig.children.find((item) => item.code === "payment-recharge-products").path).toBe(
"/app-config/recharge-products",
);
expect(payment).toBeUndefined();
});
});

View File

@ -25,7 +25,16 @@ export const PERMISSIONS = {
coinSellerCreate: "coin-seller:create",
coinSellerUpdate: "coin-seller:update",
coinSellerStockCredit: "coin-seller:stock-credit",
coinLedgerView: "coin-ledger:view",
paymentBillView: "payment-bill:view",
paymentProductView: "payment-product:view",
paymentProductCreate: "payment-product:create",
paymentProductUpdate: "payment-product:update",
paymentProductDelete: "payment-product:delete",
gameView: "game:view",
gameCreate: "game:create",
gameUpdate: "game:update",
gameStatus: "game:status",
roleView: "role:view",
roleCreate: "role:create",
roleUpdate: "role:update",
@ -46,10 +55,6 @@ export const PERMISSIONS = {
menuVisible: "menu:visible",
logView: "log:view",
logExport: "log:export",
notificationView: "notification:view",
notificationRead: "notification:read",
notificationReadAll: "notification:read-all",
notificationDelete: "notification:delete",
appUserView: "app-user:view",
appUserUpdate: "app-user:update",
appUserStatus: "app-user:status",
@ -57,8 +62,14 @@ export const PERMISSIONS = {
roomView: "room:view",
roomUpdate: "room:update",
roomDelete: "room:delete",
roomConfigView: "room-config:view",
roomConfigUpdate: "room-config:update",
appConfigView: "app-config:view",
appConfigUpdate: "app-config:update",
appVersionView: "app-version:view",
appVersionCreate: "app-version:create",
appVersionUpdate: "app-version:update",
appVersionDelete: "app-version:delete",
resourceView: "resource:view",
resourceCreate: "resource:create",
resourceUpdate: "resource:update",
@ -75,6 +86,8 @@ export const PERMISSIONS = {
dailyTaskCreate: "daily-task:create",
dailyTaskUpdate: "daily-task:update",
dailyTaskStatus: "daily-task:status",
registrationRewardView: "registration-reward:view",
registrationRewardUpdate: "registration-reward:update",
uploadCreate: "upload:create",
jobView: "job:view",
jobCancel: "job:cancel",
@ -94,26 +107,32 @@ export const MENU_CODES = {
logs: "logs",
logsLogin: "logs-login",
logsOperations: "logs-operations",
notifications: "notifications",
appUsers: "app-users",
appUserList: "app-user-list",
appUserBans: "app-user-bans",
appUserLoginLogs: "app-user-login-logs",
rooms: "rooms",
roomList: "room-list",
roomConfig: "room-config",
appConfig: "app-config",
appConfigH5: "app-config-h5",
appConfigBanners: "app-config-banners",
appConfigVersions: "app-config-versions",
resources: "resources",
resourceList: "resource-list",
resourceGroupList: "resource-group-list",
resourceGrantList: "resource-grant-list",
giftList: "gift-list",
operations: "operations",
operationCoinLedger: "operation-coin-ledger",
activities: "activities",
dailyTaskList: "daily-task-list",
registrationReward: "registration-reward",
geo: "geo",
hostOrg: "host-org",
hostOrgCountries: "host-org-countries",
hostOrgRegions: "host-org-regions",
hostOrgManagers: "host-org-managers",
hostOrgAgencies: "host-org-agencies",
hostOrgBdLeaders: "host-org-bd-leaders",
hostOrgBds: "host-org-bds",
@ -121,6 +140,9 @@ export const MENU_CODES = {
hostOrgCoinSellers: "host-org-coin-sellers",
payment: "payment",
paymentBillList: "payment-bill-list",
paymentRechargeProducts: "payment-recharge-products",
games: "games",
gameList: "game-list",
jobs: "jobs",
} as const;

View File

@ -3,11 +3,13 @@ import { appConfigRoutes } from "@/features/app-config/routes.js";
import { appUserRoutes } from "@/features/app-users/routes.js";
import { dashboardRoutes } from "@/features/dashboard/routes.js";
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
import { gameRoutes } from "@/features/games/routes.js";
import { hostOrgRoutes } from "@/features/host-org/routes.js";
import { logsRoutes } from "@/features/logs/routes.js";
import { menusRoutes } from "@/features/menus/routes.js";
import { notificationsRoutes } from "@/features/notifications/routes.js";
import { operationsRoutes } from "@/features/operations/routes.js";
import { paymentRoutes } from "@/features/payment/routes.js";
import { registrationRewardRoutes } from "@/features/registration-reward/routes.js";
import { resourceRoutes } from "@/features/resources/routes.js";
import { rolesRoutes } from "@/features/roles/routes.js";
import { roomRoutes } from "@/features/rooms/routes.js";
@ -23,14 +25,16 @@ export const adminRoutes: AdminRoute[] = [
...roomRoutes,
...appConfigRoutes,
...dailyTaskRoutes,
...registrationRewardRoutes,
...resourceRoutes,
...operationsRoutes,
...paymentRoutes,
...gameRoutes,
...usersRoutes,
...hostOrgRoutes,
...rolesRoutes,
...menusRoutes,
...logsRoutes,
...notificationsRoutes,
];
export const defaultAdminPath = "/overview";

View File

@ -1,6 +1,16 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiList, AppBannerDto, AppBannerPayload, EntityId, H5LinkConfigDto, H5LinkConfigUpdatePayload, PageQuery } from "@/shared/api/types";
import type {
ApiList,
AppBannerDto,
AppBannerPayload,
AppVersionDto,
AppVersionPayload,
EntityId,
H5LinkConfigDto,
H5LinkConfigUpdatePayload,
PageQuery,
} from "@/shared/api/types";
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
const endpoint = API_ENDPOINTS.listH5Links;
@ -47,3 +57,37 @@ export function deleteBanner(bannerId: EntityId): Promise<{ deleted: boolean }>
method: endpoint.method
});
}
export function listAppVersions(query: PageQuery = {}): Promise<ApiList<AppVersionDto>> {
const endpoint = API_ENDPOINTS.listAppVersions;
return apiRequest<ApiList<AppVersionDto>>(apiEndpointPath(API_OPERATIONS.listAppVersions), {
method: endpoint.method,
query
});
}
export function createAppVersion(payload: AppVersionPayload): Promise<AppVersionDto> {
const endpoint = API_ENDPOINTS.createAppVersion;
return apiRequest<AppVersionDto, AppVersionPayload>(apiEndpointPath(API_OPERATIONS.createAppVersion), {
body: payload,
method: endpoint.method
});
}
export function updateAppVersion(versionId: EntityId, payload: AppVersionPayload): Promise<AppVersionDto> {
const endpoint = API_ENDPOINTS.updateAppVersion;
return apiRequest<AppVersionDto, AppVersionPayload>(
apiEndpointPath(API_OPERATIONS.updateAppVersion, { version_id: versionId }),
{
body: payload,
method: endpoint.method
}
);
}
export function deleteAppVersion(versionId: EntityId): Promise<{ deleted: boolean }> {
const endpoint = API_ENDPOINTS.deleteAppVersion;
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteAppVersion, { version_id: versionId }), {
method: endpoint.method
});
}

View File

@ -23,3 +23,7 @@
.bannerCover {
object-fit: cover;
}
.formWideField {
grid-column: 1 / -1;
}

View File

@ -0,0 +1,167 @@
import { useCallback, useMemo, useState } from "react";
import { createAppVersion, deleteAppVersion, listAppVersions, updateAppVersion } from "@/features/app-config/api";
import { useAppConfigAbilities } from "@/features/app-config/permissions.js";
import { appVersionSchema } from "@/features/app-config/schema";
import { parseForm } from "@/shared/forms/validation";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const emptyData = { items: [], total: 0 };
const emptyForm = () => ({
buildNumber: "",
description: "",
downloadUrl: "",
forceUpdate: false,
platform: "android",
version: ""
});
export function useAppVersionsPage() {
const abilities = useAppConfigAbilities();
const confirm = useConfirm();
const { showToast } = useToast();
const [keyword, setKeyword] = useState("");
const [platform, setPlatform] = useState("");
const [activeAction, setActiveAction] = useState("");
const [editingItem, setEditingItem] = useState(null);
const [form, setFormState] = useState(emptyForm);
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(() => ({ keyword, platform }), [keyword, platform]);
const queryFn = useCallback(() => listAppVersions(filters), [filters]);
const {
data = emptyData,
error,
loading,
reload
} = useAdminQuery(queryFn, {
errorMessage: "加载版本配置失败",
initialData: emptyData,
queryKey: ["app-config", "versions", filters]
});
const setForm = (patch) => {
setFormState((current) => ({ ...current, ...patch }));
};
const openCreate = () => {
setEditingItem(null);
setFormState(emptyForm());
setActiveAction("create");
};
const openEdit = (item) => {
setEditingItem(item);
setFormState(formFromVersion(item));
setActiveAction("edit");
};
const closeDialog = () => {
setActiveAction("");
setEditingItem(null);
setFormState(emptyForm());
};
const resetFilters = () => {
setKeyword("");
setPlatform("");
};
const submitVersion = async (event) => {
event.preventDefault();
const payload = parseForm(appVersionSchema, normalizeForm(form));
const action = editingItem ? "edit" : "create";
setLoadingAction(action);
try {
if (editingItem) {
await updateAppVersion(editingItem.id, payload);
showToast("版本配置已更新", "success");
} else {
await createAppVersion(payload);
showToast("版本配置已新增", "success");
}
closeDialog();
await reload();
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
const removeVersion = async (item) => {
const ok = await confirm({
confirmText: "删除",
message: `${platformLabel(item.platform)} ${item.version} (${item.buildNumber})`,
title: "删除版本配置",
tone: "danger"
});
if (!ok) {
return;
}
setLoadingAction(`delete:${item.id}`);
try {
await deleteAppVersion(item.id);
await reload();
showToast("版本配置已删除", "success");
} catch (err) {
showToast(err.message || "删除失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
closeDialog,
data,
editingItem,
error,
form,
keyword,
loading,
loadingAction,
openCreate,
openEdit,
platform,
reload,
removeVersion,
resetFilters,
setForm,
setKeyword,
setPlatform,
submitVersion
};
}
function normalizeForm(form) {
return {
...form,
buildNumber: form.buildNumber || 0,
description: form.description || ""
};
}
function formFromVersion(item) {
return {
buildNumber: item.buildNumber === 0 || item.buildNumber ? String(item.buildNumber) : "",
description: item.description || "",
downloadUrl: item.downloadUrl || "",
forceUpdate: Boolean(item.forceUpdate),
platform: item.platform || "android",
version: item.version || ""
};
}
function platformLabel(value) {
if (value === "android") {
return "Android";
}
if (value === "ios") {
return "iOS";
}
return value || "-";
}

View File

@ -0,0 +1,242 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useMemo } from "react";
import { useAppVersionsPage } from "@/features/app-config/hooks/useAppVersionsPage.js";
import styles from "@/features/app-config/app-config.module.css";
import { Button } from "@/shared/ui/Button.jsx";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
AdminFormSwitchField
} from "@/shared/ui/AdminFormDialog.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions
} from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
const platformOptions = [
["", "全部平台"],
["android", "Android"],
["ios", "iOS"]
];
export function AppVersionManagementPage() {
const page = useAppVersionsPage();
const items = page.data.items || [];
const columns = useMemo(() => versionColumns(page), [page]);
const saving = page.loadingAction === "create" || page.loadingAction === "edit";
const canSubmit = page.editingItem ? page.abilities.canUpdateVersion : page.abilities.canCreateVersion;
return (
<AdminListPage>
<AdminListToolbar
actions={
page.abilities.canCreateVersion ? (
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={page.openCreate}>
新增版本
</Button>
) : null
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable columns={columns} items={items} minWidth="1080px" rowKey={(item) => item.id} />
<div className="pagination-bar">
<span>{items.length} </span>
</div>
</AdminListBody>
</DataState>
<AdminFormDialog
loading={saving}
open={Boolean(page.activeAction)}
size="narrow"
submitDisabled={!canSubmit || saving}
title={page.editingItem ? "编辑版本" : "新增版本"}
onClose={page.closeDialog}
onSubmit={page.submitVersion}
>
<AdminFormSection title="版本信息">
<AdminFormFieldGrid>
<TextField
disabled={!canSubmit}
label="平台"
required
select
value={page.form.platform}
onChange={(event) => page.setForm({ platform: event.target.value })}
>
<MenuItem value="android">Android</MenuItem>
<MenuItem value="ios">iOS</MenuItem>
</TextField>
<TextField
disabled={!canSubmit}
label="版本号"
required
value={page.form.version}
onChange={(event) => page.setForm({ version: event.target.value })}
/>
<TextField
disabled={!canSubmit}
inputProps={{ min: 1, step: 1 }}
label="编译版本号"
required
type="number"
value={page.form.buildNumber}
onChange={(event) => page.setForm({ buildNumber: event.target.value })}
/>
<AdminFormSwitchField
checked={Boolean(page.form.forceUpdate)}
checkedLabel="强更"
disabled={!canSubmit}
label="强更"
uncheckedLabel="普通"
onChange={(checked) => page.setForm({ forceUpdate: checked })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="下载信息">
<TextField
disabled={!canSubmit}
label="下载链接"
required
value={page.form.downloadUrl}
onChange={(event) => page.setForm({ downloadUrl: event.target.value })}
/>
<TextField
disabled={!canSubmit}
label="更新描述"
multiline
minRows={3}
value={page.form.description}
onChange={(event) => page.setForm({ description: event.target.value })}
/>
</AdminFormSection>
</AdminFormDialog>
</AdminListPage>
);
}
function versionColumns(page) {
const columns = [
{
key: "version",
label: "版本",
render: (item) => <Stack primary={item.version} secondary={`Build ${item.buildNumber}`} />,
filter: createTextColumnFilter({
placeholder: "搜索版本、链接、描述",
value: page.keyword,
onChange: page.setKeyword
}),
width: "minmax(190px, 0.9fr)"
},
{
key: "platform",
label: "平台",
render: (item) => platformLabel(item.platform),
filter: createOptionsColumnFilter({
options: platformOptions,
placeholder: "搜索平台",
value: page.platform,
onChange: page.setPlatform
}),
width: "minmax(120px, 0.5fr)"
},
{
key: "forceUpdate",
label: "强更",
render: (item) => <ForceBadge enabled={item.forceUpdate} />,
width: "minmax(110px, 0.45fr)"
},
{
key: "downloadUrl",
label: "下载链接",
render: (item) => <span className={styles.linkText}>{item.downloadUrl}</span>,
width: "minmax(280px, 1.25fr)"
},
{
key: "description",
label: "更新描述",
render: (item) => item.description || "-",
width: "minmax(220px, 1fr)"
},
{
key: "updatedAtMs",
label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(160px, 0.7fr)"
}
];
if (!page.abilities.canUpdateVersion && !page.abilities.canDeleteVersion) {
return columns;
}
return [
...columns,
{
key: "actions",
label: "操作",
render: (item) => <VersionActions item={item} page={page} />,
width: "minmax(96px, 0.42fr)"
}
];
}
function VersionActions({ item, page }) {
const deleting = page.loadingAction === `delete:${item.id}`;
return (
<AdminRowActions>
{page.abilities.canUpdateVersion ? (
<AdminActionIconButton disabled={Boolean(page.loadingAction)} label="编辑" onClick={() => page.openEdit(item)}>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canDeleteVersion ? (
<AdminActionIconButton disabled={deleting || Boolean(page.loadingAction)} label="删除" onClick={() => page.removeVersion(item)}>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
</AdminRowActions>
);
}
function Stack({ primary, secondary }) {
return (
<div className="cell-stack">
<span>{primary || "-"}</span>
<span className="muted">{secondary || "-"}</span>
</div>
);
}
function ForceBadge({ enabled }) {
const tone = enabled ? "warning" : "stopped";
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{enabled ? "强更" : "普通"}
</span>
);
}
function platformLabel(value) {
if (value === "android") {
return "Android";
}
if (value === "ios") {
return "iOS";
}
return value || "-";
}

View File

@ -8,10 +8,14 @@ import { useMemo } from "react";
import { useBannerConfigPage } from "@/features/app-config/hooks/useBannerConfigPage.js";
import styles from "@/features/app-config/app-config.module.css";
import { Button } from "@/shared/ui/Button.jsx";
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
@ -56,12 +60,6 @@ export function BannerConfigPage() {
</Button>
) : null
}
filters={
<AdminFilterResetButton
disabled={!page.keyword && !page.platform && !page.status && !page.regionId && !page.countryCode}
onClick={page.resetFilters}
/>
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
@ -75,7 +73,7 @@ export function BannerConfigPage() {
<AdminFormDialog
loading={page.loadingAction === "create" || page.loadingAction === "edit"}
open={Boolean(page.activeAction)}
size="narrow"
size="large"
submitDisabled={
!page.abilities.canUpdate || page.loadingAction === "create" || page.loadingAction === "edit"
}
@ -83,84 +81,95 @@ export function BannerConfigPage() {
onClose={page.closeDialog}
onSubmit={page.submitBanner}
>
<UploadField
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
label="封面图"
value={page.form.coverUrl}
onChange={(coverUrl) => page.setForm({ coverUrl })}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="类型"
required
select
value={page.form.bannerType}
onChange={(event) => page.setForm({ bannerType: event.target.value })}
>
<MenuItem value="h5">H5</MenuItem>
<MenuItem value="app">APP</MenuItem>
</TextField>
<TextField
disabled={!page.abilities.canUpdate}
label={page.form.bannerType === "h5" ? "参数H5链接" : "参数"}
required={page.form.bannerType === "h5"}
value={page.form.param}
onChange={(event) => page.setForm({ param: event.target.value })}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="状态"
required
select
value={page.form.status}
onChange={(event) => page.setForm({ status: event.target.value })}
>
<MenuItem value="active">启用</MenuItem>
<MenuItem value="disabled">关闭</MenuItem>
</TextField>
<TextField
disabled={!page.abilities.canUpdate}
label="平台"
required
select
value={page.form.platform}
onChange={(event) => page.setForm({ platform: event.target.value })}
>
<MenuItem value="android">安卓</MenuItem>
<MenuItem value="ios">iOS</MenuItem>
</TextField>
<TextField
disabled={!page.abilities.canUpdate}
inputProps={{ step: 1 }}
label="排序"
type="number"
value={page.form.sortOrder}
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
/>
<RegionSelect
disabled={!page.abilities.canUpdate}
emptyLabel="全部区域"
loading={page.loadingRegions}
options={page.regionOptions}
value={page.form.regionId}
onChange={(regionId) => page.setForm({ regionId })}
/>
<CountrySelect
disabled={!page.abilities.canUpdate || page.loadingCountries}
emptyLabel="全部国家"
label="国家"
options={page.countryOptions}
value={page.form.countryCode}
onChange={(countryCode) => page.setForm({ countryCode })}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="描述"
multiline
minRows={2}
value={page.form.description}
onChange={(event) => page.setForm({ description: event.target.value })}
/>
<AdminFormSection title="素材">
<UploadField
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
label="封面图"
value={page.form.coverUrl}
onChange={(coverUrl) => page.setForm({ coverUrl })}
/>
</AdminFormSection>
<AdminFormSection title="跳转信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={!page.abilities.canUpdate}
label="类型"
required
select
value={page.form.bannerType}
onChange={(event) => page.setForm({ bannerType: event.target.value })}
>
<MenuItem value="h5">H5</MenuItem>
<MenuItem value="app">APP</MenuItem>
</TextField>
<TextField
disabled={!page.abilities.canUpdate}
label="平台"
required
select
value={page.form.platform}
onChange={(event) => page.setForm({ platform: event.target.value })}
>
<MenuItem value="android">安卓</MenuItem>
<MenuItem value="ios">iOS</MenuItem>
</TextField>
<TextField
className={styles.formWideField}
disabled={!page.abilities.canUpdate}
label={page.form.bannerType === "h5" ? "参数H5链接" : "参数"}
required={page.form.bannerType === "h5"}
value={page.form.param}
onChange={(event) => page.setForm({ param: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="投放设置">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<RegionSelect
disabled={!page.abilities.canUpdate}
emptyLabel="全部区域"
loading={page.loadingRegions}
options={page.regionOptions}
value={page.form.regionId}
onChange={(regionId) => page.setForm({ regionId })}
/>
<CountrySelect
disabled={!page.abilities.canUpdate || page.loadingCountries}
emptyLabel="全部国家"
label="国家"
options={page.countryOptions}
value={page.form.countryCode}
onChange={(countryCode) => page.setForm({ countryCode })}
/>
<TextField
disabled={!page.abilities.canUpdate}
inputProps={{ step: 1 }}
label="排序"
type="number"
value={page.form.sortOrder}
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
/>
<AdminFormSwitchField
checked={page.form.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdate}
label="BANNER 状态"
switchProps={{ inputProps: { "aria-label": "BANNER 启用状态" } }}
uncheckedLabel="关闭"
onChange={(checked) => page.setForm({ status: checked ? "active" : "disabled" })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="描述">
<TextField
disabled={!page.abilities.canUpdate}
label="描述"
multiline
minRows={2}
value={page.form.description}
onChange={(event) => page.setForm({ description: event.target.value })}
/>
</AdminFormSection>
</AdminFormDialog>
</AdminListPage>
);

View File

@ -2,7 +2,7 @@ import EditOutlined from "@mui/icons-material/EditOutlined";
import LinkOutlined from "@mui/icons-material/LinkOutlined";
import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js";
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
@ -74,13 +74,15 @@ export function H5ConfigPage() {
onClose={page.closeEdit}
onSubmit={page.submitEdit}
>
<TextField
autoFocus
disabled={!page.abilities.canUpdate}
label={page.editingItem?.label || "H5链接"}
value={page.form.url}
onChange={(event) => page.setForm({ url: event.target.value })}
/>
<AdminFormSection title="链接信息">
<TextField
autoFocus
disabled={!page.abilities.canUpdate}
label={page.editingItem?.label || "H5链接"}
value={page.form.url}
onChange={(event) => page.setForm({ url: event.target.value })}
/>
</AdminFormSection>
</AdminFormDialog>
</AdminListPage>
);

View File

@ -5,8 +5,12 @@ export function useAppConfigAbilities() {
const { can } = useAuth();
return {
canCreateVersion: can(PERMISSIONS.appVersionCreate),
canDeleteVersion: can(PERMISSIONS.appVersionDelete),
canUpdate: can(PERMISSIONS.appConfigUpdate),
canUpdateVersion: can(PERMISSIONS.appVersionUpdate),
canUpload: can(PERMISSIONS.uploadCreate),
canView: can(PERMISSIONS.appConfigView)
canView: can(PERMISSIONS.appConfigView),
canViewVersion: can(PERMISSIONS.appVersionView)
};
}

View File

@ -16,5 +16,22 @@ export const appConfigRoutes = [
pageKey: "app-config-banners",
path: "/app-config/banners",
permission: PERMISSIONS.appConfigView
},
{
label: "内购配置",
loader: () =>
import("../payment/pages/RechargeProductConfigPage.jsx").then((module) => module.RechargeProductConfigPage),
menuCode: MENU_CODES.paymentRechargeProducts,
pageKey: "payment-recharge-products",
path: "/app-config/recharge-products",
permission: PERMISSIONS.paymentProductView
},
{
label: "版本管理",
loader: () => import("./pages/AppVersionManagementPage.jsx").then((module) => module.AppVersionManagementPage),
menuCode: MENU_CODES.appConfigVersions,
pageKey: "app-config-versions",
path: "/app-config/versions",
permission: PERMISSIONS.appVersionView
}
];

View File

@ -38,3 +38,15 @@ export const appBannerSchema = z.object({
});
export type AppBannerForm = z.infer<typeof appBannerSchema>;
export const appVersionSchema = z.object({
buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"),
description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""),
downloadUrl: z.string().trim().min(1, "请填写下载链接").max(2048, "下载链接不能超过 2048 个字符")
.refine((value) => !/\s/.test(value), "下载链接不能包含空白字符"),
forceUpdate: z.boolean(),
platform: z.enum(["android", "ios"]),
version: z.string().trim().min(1, "请填写版本号").max(40, "版本号不能超过 40 个字符")
});
export type AppVersionForm = z.infer<typeof appVersionSchema>;

View File

@ -2,6 +2,7 @@ import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type {
ApiPage,
AppUserBanRecordDto,
AppUserLoginLogDto,
AppUserDto,
AppUserPasswordPayload,
@ -26,6 +27,14 @@ export function listAppUserLoginLogs(query: PageQuery = {}): Promise<ApiPage<App
});
}
export function listAppUserBans(query: PageQuery = {}): Promise<ApiPage<AppUserBanRecordDto>> {
const endpoint = API_ENDPOINTS.appListBannedUsers;
return apiRequest<ApiPage<AppUserBanRecordDto>>(apiEndpointPath(API_OPERATIONS.appListBannedUsers), {
method: endpoint.method,
query,
});
}
export function updateAppUser(userId: EntityId, payload: AppUserUpdatePayload): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appUpdateUser;
return apiRequest<AppUserDto, AppUserUpdatePayload>(apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }), {

View File

@ -0,0 +1,194 @@
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useCallback, useMemo, useState } from "react";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { formatMillis } from "@/shared/utils/time.js";
import { listAppUserBans } from "@/features/app-users/api";
import { appUserStatusLabels } from "@/features/app-users/constants.js";
import styles from "@/features/app-users/app-users.module.css";
const pageSize = 20;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
export function AppUserBanListPage() {
const [targetKeyword, setTargetKeyword] = useState("");
const [operatorKeyword, setOperatorKeyword] = useState("");
const [page, setPage] = useState(1);
const filters = useMemo(
() => ({
operator_keyword: operatorKeyword,
target_keyword: targetKeyword,
}),
[operatorKeyword, targetKeyword],
);
const {
data = emptyData,
error,
loading,
reload,
} = usePaginatedQuery({
errorMessage: "加载封禁列表失败",
fetcher: listAppUserBans,
filters,
page,
pageSize,
queryKey: ["app-users", "bans", filters, page],
});
const changeTargetKeyword = useCallback((value) => {
setTargetKeyword(value);
setPage(1);
}, []);
const changeOperatorKeyword = useCallback((value) => {
setOperatorKeyword(value);
setPage(1);
}, []);
const columns = useMemo(
() => [
{
key: "target",
label: "封禁用户",
width: "minmax(280px, 1.4fr)",
filter: createTextColumnFilter({
placeholder: "搜索昵称、短 ID、长 ID",
value: targetKeyword,
onChange: changeTargetKeyword,
}),
render: (record) => <TargetIdentity target={record.target} />,
},
{
key: "operator",
label: "封禁人",
width: "minmax(280px, 1.4fr)",
filter: createTextColumnFilter({
placeholder: "搜索账号、昵称、短 ID、长 ID",
value: operatorKeyword,
onChange: changeOperatorKeyword,
}),
render: (record) => <OperatorIdentity operator={record.operator} />,
},
{
key: "status",
label: "状态",
width: "minmax(110px, 0.6fr)",
render: (record) => <UserStatus status={record.newStatus} />,
},
{
key: "reason",
label: "原因",
width: "minmax(190px, 1fr)",
render: (record) => <span className="muted">{record.reason || "-"}</span>,
},
{
key: "createdAtMs",
label: "封禁时间",
width: "190px",
render: (record) => <span className="muted">{formatMillis(record.createdAtMs)}</span>,
},
],
[changeOperatorKeyword, changeTargetKeyword, operatorKeyword, targetKeyword],
);
return (
<section className={styles.root}>
<div className={styles.contentPanel}>
<DataState error={error} loading={loading} onRetry={reload}>
<div className={styles.listBlock}>
<DataTable
columns={columns}
items={data.items || []}
minWidth="1180px"
pagination={
data.total > 0
? {
page,
pageSize: data.pageSize || pageSize,
total: data.total,
onPageChange: setPage,
}
: undefined
}
rowKey={(record) => record.id}
/>
</div>
</DataState>
</div>
</section>
);
}
function TargetIdentity({ target }) {
return (
<UserIdentity
avatar={target?.avatar}
name={target?.username}
primaryFallback={target?.displayUserId || target?.userId}
rows={[target?.displayUserId, target?.userId]}
/>
);
}
function OperatorIdentity({ operator }) {
if (!operator) {
return <span className="muted">-</span>;
}
if (operator.type === "admin") {
return (
<UserIdentity
name={operator.account}
primaryFallback={operator.adminId}
rows={[operator.name, operator.adminId ? `Admin ID ${operator.adminId}` : "后台管理员"]}
/>
);
}
if (operator.type === "app_user") {
return (
<UserIdentity
avatar={operator.avatar}
name={operator.name}
primaryFallback={operator.displayUserId || operator.userId}
rows={[operator.displayUserId, operator.userId]}
/>
);
}
return <span className="muted">system</span>;
}
function UserIdentity({ avatar, name, primaryFallback, rows }) {
const title = name || primaryFallback || "-";
const visibleRows = rows.filter(Boolean);
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{avatar ? <img src={avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<div className={styles.nameLine}>
<span className={styles.name}>{title}</span>
</div>
{visibleRows.length ? (
<div className={styles.stack}>
{visibleRows.map((value, index) => (
<span className={styles.meta} key={`${value}-${index}`}>
{value}
</span>
))}
</div>
) : null}
</div>
</div>
);
}
function UserStatus({ status }) {
return (
<span className="status-badge status-badge--danger">
<span className="status-point" />
{appUserStatusLabels[status] || status || "-"}
</span>
);
}

View File

@ -7,12 +7,10 @@ import Autocomplete from "@mui/material/Autocomplete";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js";
@ -91,28 +89,24 @@ export function AppUserListPage() {
return (
<section className={styles.root}>
<div className={styles.contentPanel}>
<div className={styles.toolbar}>
<section className={styles.filters}>
<AdminFilterResetButton disabled={!page.query && !page.status} onClick={page.resetFilters} />
</section>
</div>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}>
<DataTable
columns={tableColumns}
items={items}
minWidth="1220px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(user) => user.userId}
/>
{total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</div>
</DataState>
</div>
@ -121,6 +115,7 @@ export function AppUserListPage() {
disabled={!page.abilities.canUpdate}
loading={page.loadingAction === "edit"}
open={page.activeAction === "edit"}
sectionTitle="用户资料"
title="编辑用户"
onClose={page.closeAction}
onSubmit={page.submitEdit}
@ -156,6 +151,7 @@ export function AppUserListPage() {
disabled={!page.abilities.canUpdate}
loading={page.loadingAction === "country"}
open={page.activeAction === "country"}
sectionTitle="国家信息"
title="修改国家"
onClose={page.closeAction}
onSubmit={page.submitCountry}
@ -177,6 +173,7 @@ export function AppUserListPage() {
disabled={!page.abilities.canPassword}
loading={page.loadingAction === "password"}
open={page.activeAction === "password"}
sectionTitle="密码信息"
title="设置密码"
onClose={page.closeAction}
onSubmit={page.submitPassword}
@ -280,7 +277,7 @@ function UserStatus({ status }) {
);
}
function ActionModal({ children, disabled, loading, onClose, onSubmit, open, title }) {
function ActionModal({ children, disabled, loading, onClose, onSubmit, open, sectionTitle = "基本信息", title }) {
return (
<AdminFormDialog
loading={loading}
@ -291,7 +288,7 @@ function ActionModal({ children, disabled, loading, onClose, onSubmit, open, tit
onClose={onClose}
onSubmit={onSubmit}
>
{children}
<AdminFormSection title={sectionTitle}>{children}</AdminFormSection>
</AdminFormDialog>
);
}

View File

@ -4,9 +4,6 @@ import { useSearchParams } from "react-router-dom";
import { toPageQuery } from "@/shared/api/query";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
import { FilterChip } from "@/shared/ui/FilterChip.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
@ -68,11 +65,6 @@ export function AppUserLoginLogsPage() {
setPage(1);
}, []);
const clearUserFilter = useCallback(() => {
setSearchParams(new URLSearchParams(), { replace: true });
setPage(1);
}, [setSearchParams]);
const showUserHistory = useCallback(
(log) => {
if (!log.userId) {
@ -89,13 +81,29 @@ export function AppUserLoginLogsPage() {
[searchParams, setSearchParams],
);
const resetFilters = () => {
const resetIdentityFilter = useCallback(() => {
setQuery("");
setResult("");
setRegionId("");
setSearchParams(new URLSearchParams(), { replace: true });
setPage(1);
};
}, [setSearchParams]);
const changeIdentityFilter = useCallback(
(value) => {
if (userFilter) {
setSearchParams(new URLSearchParams(), { replace: true });
}
changeQuery(value);
},
[changeQuery, setSearchParams, userFilter],
);
const selectedUserLabel = useMemo(() => {
if (!userFilter) {
return "";
}
const matchedLog = (data.items || []).find((log) => String(log.userId || "") === String(userFilter));
return matchedLog?.displayUserId || matchedLog?.username || userFilter;
}, [data.items, userFilter]);
const columns = useMemo(
() => [
@ -104,9 +112,12 @@ export function AppUserLoginLogsPage() {
label: "用户",
width: "minmax(240px, 1.4fr)",
filter: createTextColumnFilter({
activeLabel: userFilter ? selectedUserLabel || userFilter : undefined,
activeValue: query || userFilter,
onReset: resetIdentityFilter,
placeholder: "搜索昵称、短 ID、用户 ID、IP",
value: query,
onChange: changeQuery,
onChange: changeIdentityFilter,
}),
render: (log) => <UserIdentity log={log} />,
},
@ -188,31 +199,24 @@ export function AppUserLoginLogsPage() {
render: (log) => <span className="muted">{formatMillis(log.createdAtMs)}</span>,
},
],
[changeQuery, changeRegionId, changeResult, loadingRegions, query, regionFilterOptions, regionId, result],
[
changeIdentityFilter,
changeRegionId,
changeResult,
loadingRegions,
query,
regionFilterOptions,
regionId,
resetIdentityFilter,
result,
selectedUserLabel,
userFilter,
],
);
const selectedUserLabel = useMemo(() => {
if (!userFilter) {
return "";
}
const matchedLog = (data.items || []).find((log) => String(log.userId || "") === String(userFilter));
return matchedLog?.displayUserId || matchedLog?.username || userFilter;
}, [data.items, userFilter]);
return (
<section className={styles.root}>
<div className={styles.contentPanel}>
<div className={styles.toolbar}>
<section className={styles.filters}>
{userFilter ? (
<FilterChip active label={`用户 ${selectedUserLabel || userFilter}`} onClick={clearUserFilter} />
) : null}
<AdminFilterResetButton
disabled={!query && !result && !regionId && !userFilter}
onClick={resetFilters}
/>
</section>
</div>
<DataState error={error} loading={loading} onRetry={reload}>
<div className={styles.listBlock}>
<DataTable
@ -222,17 +226,17 @@ export function AppUserLoginLogsPage() {
minWidth="1380px"
getRowProps={(log) => loginLogRowProps(log, showUserHistory)}
rowKey={(log) => log.id}
title="登录日志"
total={data.total}
pagination={
data.total > 0
? {
page,
pageSize: data.pageSize || pageSize,
total: data.total,
onPageChange: setPage,
}
: undefined
}
/>
{data.total > 0 ? (
<PaginationBar
page={page}
pageSize={data.pageSize || pageSize}
total={data.total}
onPageChange={setPage}
/>
) : null}
</div>
</DataState>
</div>

View File

@ -17,4 +17,12 @@ export const appUserRoutes = [
path: "/app/users/login-logs",
permission: PERMISSIONS.appUserView,
},
{
label: "封禁列表",
loader: () => import("./pages/AppUserBanListPage.jsx").then((module) => module.AppUserBanListPage),
menuCode: MENU_CODES.appUserBans,
pageKey: "app-user-bans",
path: "/app/users/bans",
permission: PERMISSIONS.appUserView,
},
];

View File

@ -1,9 +1,14 @@
import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
import Switch from "@mui/material/Switch";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminFormDialog, AdminFormFieldGrid } from "@/shared/ui/AdminFormDialog.jsx";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
@ -15,7 +20,6 @@ import {
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import {
@ -175,15 +179,22 @@ export function DailyTaskListPage() {
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable columns={columns} items={items} minWidth="1340px" rowKey={(task) => task.taskId} />
{total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
<DataTable
columns={columns}
items={items}
minWidth="1340px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(task) => task.taskId}
/>
</AdminListBody>
</DataState>
<TaskFormDialog
@ -218,7 +229,7 @@ function TaskStatusSwitch({ page, task }) {
!page.abilities.canStatus || task.status === "archived" || page.loadingAction === `status-${task.taskId}`;
return (
<div className={styles.statusCell}>
<Switch
<AdminSwitch
checked={checked}
disabled={disabled}
inputProps={{ "aria-label": checked ? "暂停任务" : "启用任务" }}
@ -254,111 +265,118 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormFieldGrid>
<TextField
disabled={disabled}
label="任务类型"
required
select
value={form.taskType}
onChange={(event) => page.setForm({ ...form, taskType: event.target.value })}
>
{taskTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="状态"
required
select
value={form.status}
onChange={(event) => page.setForm({ ...form, status: event.target.value })}
>
{taskStatusOptions
.filter(([value]) => value)
.map(([value, label]) => (
<AdminFormSection title="基础信息">
<AdminFormFieldGrid>
<TextField
disabled={disabled}
label="任务类型"
required
select
value={form.taskType}
onChange={(event) => page.setForm({ ...form, taskType: event.target.value })}
>
{taskTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="任务名称"
required
value={form.title}
onChange={(event) => page.setForm({ ...form, title: event.target.value })}
/>
<TextField
disabled={disabled}
label="任务分类"
required
value={form.category}
onChange={(event) => page.setForm({ ...form, category: event.target.value })}
/>
<TextField
disabled={disabled}
label="指标类型"
required
value={form.metricType}
onChange={(event) => page.setForm({ ...form, metricType: event.target.value })}
/>
<TextField
disabled={disabled}
label="目标单位"
required
select
value={form.targetUnit}
onChange={(event) => page.setForm({ ...form, targetUnit: event.target.value })}
>
{taskUnitOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="目标值"
required
type="number"
value={form.targetValue}
onChange={(event) => page.setForm({ ...form, targetValue: event.target.value })}
/>
<TextField
disabled={disabled}
label="奖励金币"
required
type="number"
value={form.rewardCoinAmount}
onChange={(event) => page.setForm({ ...form, rewardCoinAmount: event.target.value })}
/>
<TextField
disabled={disabled}
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
/>
<TextField
disabled={disabled}
label="有效开始时间"
slotProps={{ inputLabel: { shrink: true } }}
type="datetime-local"
value={form.effectiveFrom}
onChange={(event) => page.setForm({ ...form, effectiveFrom: event.target.value })}
/>
<TextField
disabled={disabled}
label="有效结束时间"
slotProps={{ inputLabel: { shrink: true } }}
type="datetime-local"
value={form.effectiveTo}
onChange={(event) => page.setForm({ ...form, effectiveTo: event.target.value })}
/>
</TextField>
<AdminFormSwitchField
checked={form.status === "active"}
checkedLabel="启用"
disabled={disabled || form.status === "archived"}
label="任务状态"
switchProps={{ inputProps: { "aria-label": "任务启用状态" } }}
uncheckedLabel={taskSwitchUncheckedLabel(form.status, mode)}
onChange={(checked) =>
page.setForm({ ...form, status: nextTaskStatusFromSwitch(form.status, checked, mode) })
}
/>
<TextField
disabled={disabled}
label="任务名称"
required
value={form.title}
onChange={(event) => page.setForm({ ...form, title: event.target.value })}
/>
<TextField
disabled={disabled}
label="任务分类"
required
value={form.category}
onChange={(event) => page.setForm({ ...form, category: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="目标与奖励">
<AdminFormFieldGrid>
<TextField
disabled={disabled}
label="指标类型"
required
value={form.metricType}
onChange={(event) => page.setForm({ ...form, metricType: event.target.value })}
/>
<TextField
disabled={disabled}
label="目标单位"
required
select
value={form.targetUnit}
onChange={(event) => page.setForm({ ...form, targetUnit: event.target.value })}
>
{taskUnitOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="目标值"
required
type="number"
value={form.targetValue}
onChange={(event) => page.setForm({ ...form, targetValue: event.target.value })}
/>
<TextField
disabled={disabled}
label="奖励金币"
required
type="number"
value={form.rewardCoinAmount}
onChange={(event) => page.setForm({ ...form, rewardCoinAmount: event.target.value })}
/>
<TextField
disabled={disabled}
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="有效期">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={disabled}
label="有效开始时间"
slotProps={{ inputLabel: { shrink: true } }}
type="datetime-local"
value={form.effectiveFrom}
onChange={(event) => page.setForm({ ...form, effectiveFrom: event.target.value })}
/>
<TextField
disabled={disabled}
label="有效结束时间"
slotProps={{ inputLabel: { shrink: true } }}
type="datetime-local"
value={form.effectiveTo}
onChange={(event) => page.setForm({ ...form, effectiveTo: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="说明">
<TextField
disabled={disabled}
label="任务描述"
@ -367,7 +385,7 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
value={form.description}
onChange={(event) => page.setForm({ ...form, description: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
@ -380,6 +398,23 @@ function taskStatusLabel(value) {
return taskStatusLabels[value] || value || "-";
}
function taskSwitchUncheckedLabel(status, mode) {
if (status === "archived") {
return "归档";
}
return status === "draft" || mode === "create" ? "草稿" : "暂停";
}
function nextTaskStatusFromSwitch(status, checked, mode) {
if (checked) {
return "active";
}
if (status === "archived") {
return "archived";
}
return status === "draft" || mode === "create" ? "draft" : "paused";
}
function taskUnitLabel(value) {
return taskUnitLabels[value] || value || "";
}

View File

@ -1,16 +0,0 @@
export function DashboardAlerts({ alerts = [] }) {
return (
<section className="alert-strip" aria-label="实时告警">
{alerts.map((alert) => (
<div className="alert-item" key={`${alert.name}-${alert.time}`}>
<span className={`alert-dot alert-dot--${alert.type}`} />
<div>
<div className="alert-name">{alert.name}</div>
<div className="alert-desc">{alert.desc}</div>
</div>
<div className="alert-time">{alert.time}</div>
</div>
))}
</section>
);
}

View File

@ -1,5 +1,3 @@
import { Card } from "@/shared/ui/Card.jsx";
import { BarChart } from "@/shared/charts/BarChart.jsx";
import { ChartCard } from "@/shared/charts/ChartCard.jsx";
export function DashboardCharts({ overview }) {
@ -7,12 +5,6 @@ export function DashboardCharts({ overview }) {
<section className="overview-charts" aria-label="资源图表">
<ChartCard title="后台用户" colorToken="--chart-blue" series={overview?.series?.users || []} />
<ChartCard title="操作日志" colorToken="--chart-green" series={overview?.series?.operations || []} />
<Card className="chart-card">
<div className="card-head">
<div className="card-title">通知趋势</div>
</div>
<BarChart series={overview?.series?.notifications || []} />
</Card>
</section>
);
}

View File

@ -1,6 +1,5 @@
import DeveloperBoardOutlined from "@mui/icons-material/DeveloperBoardOutlined";
import DnsOutlined from "@mui/icons-material/DnsOutlined";
import SecurityOutlined from "@mui/icons-material/SecurityOutlined";
import StorageOutlined from "@mui/icons-material/StorageOutlined";
import { KpiCard } from "@/shared/ui/KpiCard.jsx";
@ -10,7 +9,6 @@ export function DashboardStats({ overview, stats }) {
<KpiCard icon={DnsOutlined} label="后台用户" value={overview?.usersTotal || 0} unit="个" sub={<span className="status-ok">启用 {stats.activeUsers}</span>} />
<KpiCard icon={DeveloperBoardOutlined} label="角色数量" value={stats.rolesTotal} unit="个" sub={`菜单 ${stats.menuTotal}`} />
<KpiCard icon={StorageOutlined} label="今日操作" value={stats.logsToday} unit="条" tone="info" sub="操作日志" />
<KpiCard icon={SecurityOutlined} label="未读通知" value={stats.unreadNotifications} unit="条" tone="warning" sub={<><span className="status-warn">锁定 {stats.lockedUsers}</span> / 停用 {stats.disabledUsers}</>} />
</section>
);
}

View File

@ -23,7 +23,7 @@ export function useDashboardPage() {
const stats = useMemo(() => {
if (!overview) {
return { activeUsers: 0, disabledUsers: 0, lockedUsers: 0, logsToday: 0, menuTotal: 0, rolesTotal: 0, unreadNotifications: 0, usersTotal: 0 };
return { activeUsers: 0, disabledUsers: 0, lockedUsers: 0, logsToday: 0, menuTotal: 0, rolesTotal: 0, usersTotal: 0 };
}
return {
activeUsers: overview.activeUsers,
@ -32,7 +32,6 @@ export function useDashboardPage() {
logsToday: overview.logsToday,
menuTotal: overview.menuTotal,
rolesTotal: overview.rolesTotal,
unreadNotifications: overview.unreadNotifications,
usersTotal: overview.usersTotal
};
}, [overview]);

View File

@ -1,5 +1,4 @@
import { DataState } from "@/shared/ui/DataState.jsx";
import { DashboardAlerts } from "@/features/dashboard/components/DashboardAlerts.jsx";
import { DashboardCharts } from "@/features/dashboard/components/DashboardCharts.jsx";
import { DashboardStats } from "@/features/dashboard/components/DashboardStats.jsx";
import { DashboardToolbar } from "@/features/dashboard/components/DashboardToolbar.jsx";
@ -20,8 +19,6 @@ export function OverviewPage() {
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<DashboardStats overview={page.overview} stats={page.stats} />
<DashboardCharts overview={page.overview} />
<DashboardAlerts alerts={page.overview?.alerts || []} />
</DataState>
</>
);

155
src/features/games/api.ts Normal file
View File

@ -0,0 +1,155 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { QueryParams } from "@/shared/api/request";
export interface GamePlatformDto {
appCode?: string;
platformCode: string;
platformName: string;
status: string;
apiBaseUrl: string;
sortOrder: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface GameCatalogDto {
appCode?: string;
gameId: string;
platformCode: string;
providerGameId: string;
gameName: string;
category: string;
iconUrl: string;
coverUrl: string;
launchMode: string;
orientation: string;
minCoin: number;
status: string;
sortOrder: number;
tags: string[];
createdAtMs?: number;
updatedAtMs?: number;
}
export interface GameCatalogPage {
items: GameCatalogDto[];
nextCursor?: string;
pageSize?: number;
serverTimeMs?: number;
}
export interface GamePlatformPage {
items: GamePlatformDto[];
serverTimeMs?: number;
}
export type GamePlatformPayload = Omit<GamePlatformDto, "appCode" | "createdAtMs" | "updatedAtMs">;
export type GameCatalogPayload = Omit<GameCatalogDto, "appCode" | "createdAtMs" | "updatedAtMs">;
export interface GameCatalogQuery {
platformCode?: string;
status?: string;
pageSize?: number;
cursor?: string;
}
export function listGamePlatforms(status = ""): Promise<GamePlatformPage> {
const endpoint = API_ENDPOINTS.listPlatforms;
return apiRequest<GamePlatformPage>(apiEndpointPath(API_OPERATIONS.listPlatforms), {
method: endpoint.method,
query: { status },
}).then((page) => ({
...page,
items: (page.items || []).map(normalizePlatform),
}));
}
export function upsertGamePlatform(payload: GamePlatformPayload, platformCode = ""): Promise<GamePlatformDto> {
const operation = platformCode ? API_OPERATIONS.updatePlatform : API_OPERATIONS.createPlatform;
const endpoint = API_ENDPOINTS[operation];
return apiRequest<GamePlatformDto, GamePlatformPayload>(
apiEndpointPath(operation, platformCode ? { platform_code: platformCode } : {}),
{
body: payload,
method: endpoint.method,
},
).then(normalizePlatform);
}
export function listGameCatalog(query: GameCatalogQuery = {}): Promise<GameCatalogPage> {
const endpoint = API_ENDPOINTS.listCatalog;
return apiRequest<GameCatalogPage>(apiEndpointPath(API_OPERATIONS.listCatalog), {
method: endpoint.method,
query: query as QueryParams,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizeCatalog),
}));
}
export function upsertGameCatalog(payload: GameCatalogPayload, gameId = ""): Promise<GameCatalogDto> {
const operation = gameId ? API_OPERATIONS.updateCatalog : API_OPERATIONS.createCatalog;
const endpoint = API_ENDPOINTS[operation];
return apiRequest<GameCatalogDto, GameCatalogPayload>(
apiEndpointPath(operation, gameId ? { game_id: gameId } : {}),
{
body: payload,
method: endpoint.method,
},
).then(normalizeCatalog);
}
export function setGameStatus(gameId: string, status: string): Promise<GameCatalogDto> {
const endpoint = API_ENDPOINTS.setGameStatus;
return apiRequest<GameCatalogDto, { status: string }>(
apiEndpointPath(API_OPERATIONS.setGameStatus, { game_id: gameId }),
{
body: { status },
method: endpoint.method,
},
).then(normalizeCatalog);
}
function normalizePlatform(platform: Partial<GamePlatformDto>): GamePlatformDto {
return {
appCode: stringValue(platform.appCode),
platformCode: stringValue(platform.platformCode),
platformName: stringValue(platform.platformName),
status: stringValue(platform.status || "active"),
apiBaseUrl: stringValue(platform.apiBaseUrl),
sortOrder: numberValue(platform.sortOrder),
createdAtMs: numberValue(platform.createdAtMs),
updatedAtMs: numberValue(platform.updatedAtMs),
};
}
function normalizeCatalog(game: Partial<GameCatalogDto>): GameCatalogDto {
return {
appCode: stringValue(game.appCode),
gameId: stringValue(game.gameId),
platformCode: stringValue(game.platformCode),
providerGameId: stringValue(game.providerGameId),
gameName: stringValue(game.gameName),
category: stringValue(game.category),
iconUrl: stringValue(game.iconUrl),
coverUrl: stringValue(game.coverUrl),
launchMode: stringValue(game.launchMode || "h5_popup"),
orientation: stringValue(game.orientation || "portrait"),
minCoin: numberValue(game.minCoin),
status: stringValue(game.status || "active"),
sortOrder: numberValue(game.sortOrder),
tags: Array.isArray(game.tags) ? game.tags.map(String).filter(Boolean) : [],
createdAtMs: numberValue(game.createdAtMs),
updatedAtMs: numberValue(game.updatedAtMs),
};
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}
function numberValue(value: unknown) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}

View File

@ -0,0 +1,57 @@
.identity {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.icon {
width: 44px;
height: 44px;
flex: 0 0 auto;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card-strong);
object-fit: cover;
}
.identityText,
.stack {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.name {
overflow: hidden;
color: var(--text-primary);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta,
.tags {
overflow: hidden;
color: var(--text-tertiary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.tags {
color: var(--text-secondary);
}
.switchOnlyField {
display: inline-flex;
min-height: 56px;
align-items: center;
justify-content: flex-start;
width: 100%;
}
.fullField {
grid-column: 1 / -1;
}

View File

@ -0,0 +1,303 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
listGameCatalog,
listGamePlatforms,
setGameStatus,
upsertGameCatalog,
upsertGamePlatform,
} from "@/features/games/api";
import { useGameAbilities } from "@/features/games/permissions.js";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const defaultGameForm = {
gameId: "",
platformCode: "",
providerGameId: "",
gameName: "",
category: "casual",
iconUrl: "",
coverUrl: "",
launchMode: "h5_popup",
orientation: "portrait",
minCoin: 0,
status: "active",
sortOrder: 0,
tagsText: "",
};
const defaultPlatformForm = {
platformCode: "",
platformName: "",
status: "active",
apiBaseUrl: "",
sortOrder: 0,
};
const emptyCatalog = { items: [], pageSize: 50 };
const emptyPlatforms = { items: [] };
const defaultPageSize = 50;
export function useGamesPage() {
const abilities = useGameAbilities();
const { showToast } = useToast();
const [platformCode, setPlatformCode] = useState("");
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const [pageCursors, setPageCursors] = useState({ 1: "" });
const [pageSize, setPageSize] = useState(defaultPageSize);
const [activeAction, setActiveAction] = useState("");
const [editingGameId, setEditingGameId] = useState("");
const [editingPlatformCode, setEditingPlatformCode] = useState("");
const [gameForm, setGameForm] = useState(defaultGameForm);
const [platformForm, setPlatformForm] = useState(defaultPlatformForm);
const [loadingAction, setLoadingAction] = useState("");
const platformsQueryFn = useCallback(() => listGamePlatforms(), []);
const {
data: platformData = emptyPlatforms,
error: platformError,
loading: platformsLoading,
reload: reloadPlatforms,
} = useAdminQuery(platformsQueryFn, {
errorMessage: "加载游戏平台失败",
initialData: emptyPlatforms,
queryKey: ["games", "platforms"],
});
const catalogQueryFn = useCallback(
() => listGameCatalog({ cursor: pageCursors[page] || "", platformCode, status, pageSize }),
[page, pageCursors, pageSize, platformCode, status],
);
const {
data = emptyCatalog,
error,
loading,
reload,
} = useAdminQuery(catalogQueryFn, {
errorMessage: "加载游戏列表失败",
initialData: emptyCatalog,
keepPreviousData: true,
queryKey: ["games", "catalog", platformCode, status, pageSize, page, pageCursors[page] || ""],
});
const platformOptions = useMemo(
() => (platformData.items || []).map((platform) => [platform.platformCode, platform.platformName]),
[platformData.items],
);
useEffect(() => {
if (!gameForm.platformCode && platformOptions.length > 0 && activeAction === "create-game") {
setGameForm((current) => ({ ...current, platformCode: platformOptions[0][0] }));
}
}, [activeAction, gameForm.platformCode, platformOptions]);
const resetCatalogPage = () => {
setPage(1);
setPageCursors({ 1: "" });
};
const changePlatformCode = (value) => {
setPlatformCode(value);
resetCatalogPage();
};
const changeStatus = (value) => {
setStatus(value);
resetCatalogPage();
};
const changePageSize = (value) => {
setPageSize(value);
resetCatalogPage();
};
const changePage = (nextPage) => {
if (nextPage < 1 || nextPage === page) {
return;
}
if (nextPage === page + 1) {
if (!data.nextCursor) {
return;
}
setPageCursors((current) => ({ ...current, [nextPage]: data.nextCursor }));
}
setPage(nextPage);
};
const resetFilters = () => {
setPlatformCode("");
setStatus("");
setPageSize(defaultPageSize);
resetCatalogPage();
};
const openCreateGame = () => {
setEditingGameId("");
setGameForm({ ...defaultGameForm, platformCode: platformOptions[0]?.[0] || "" });
setActiveAction("create-game");
};
const openEditGame = (game) => {
setEditingGameId(game.gameId);
setGameForm(gameToForm(game));
setActiveAction("edit-game");
};
const openCreatePlatform = () => {
setEditingPlatformCode("");
setPlatformForm(defaultPlatformForm);
setActiveAction("create-platform");
};
const openEditPlatform = (platform) => {
setEditingPlatformCode(platform.platformCode);
setPlatformForm(platformToForm(platform));
setActiveAction("edit-platform");
};
const closeAction = () => {
setActiveAction("");
setEditingGameId("");
setEditingPlatformCode("");
};
const submitGame = async (event) => {
event.preventDefault();
const payload = gamePayload(gameForm);
setLoadingAction(activeAction);
try {
await upsertGameCatalog(payload, editingGameId);
await reload();
showToast(editingGameId ? "游戏已更新" : "游戏已创建", "success");
closeAction();
} catch (err) {
showToast(err.message || "保存游戏失败", "error");
} finally {
setLoadingAction("");
}
};
const submitPlatform = async (event) => {
event.preventDefault();
const payload = platformPayload(platformForm);
setLoadingAction(activeAction);
try {
await upsertGamePlatform(payload, editingPlatformCode);
await reloadPlatforms();
await reload();
showToast(editingPlatformCode ? "平台已更新" : "平台已创建", "success");
closeAction();
} catch (err) {
showToast(err.message || "保存平台失败", "error");
} finally {
setLoadingAction("");
}
};
const changeGameStatus = async (game, checked) => {
const nextStatus = checked ? "active" : "maintenance";
setLoadingAction(`status-${game.gameId}`);
try {
await setGameStatus(game.gameId, nextStatus);
await reload();
showToast("游戏状态已更新", "success");
} catch (err) {
showToast(err.message || "状态更新失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
changeGameStatus,
closeAction,
data,
error: error || platformError,
gameForm,
loading: loading || platformsLoading,
loadingAction,
page,
openCreateGame,
openCreatePlatform,
openEditGame,
openEditPlatform,
pageSize,
platformCode,
platformData,
platformForm,
platformOptions,
reload,
resetFilters,
changePage,
changePageSize,
setGameForm,
setPlatformCode: changePlatformCode,
setPlatformForm,
setStatus: changeStatus,
status,
submitGame,
submitPlatform,
};
}
function gameToForm(game) {
return {
gameId: game.gameId || "",
platformCode: game.platformCode || "",
providerGameId: game.providerGameId || "",
gameName: game.gameName || "",
category: game.category || "casual",
iconUrl: game.iconUrl || "",
coverUrl: game.coverUrl || "",
launchMode: game.launchMode || "h5_popup",
orientation: game.orientation || "portrait",
minCoin: Number(game.minCoin || 0),
status: game.status || "active",
sortOrder: Number(game.sortOrder || 0),
tagsText: (game.tags || []).join(", "),
};
}
function platformToForm(platform) {
return {
platformCode: platform.platformCode || "",
platformName: platform.platformName || "",
status: platform.status || "active",
apiBaseUrl: platform.apiBaseUrl || "",
sortOrder: Number(platform.sortOrder || 0),
};
}
function gamePayload(form) {
return {
gameId: form.gameId.trim(),
platformCode: form.platformCode.trim(),
providerGameId: form.providerGameId.trim(),
gameName: form.gameName.trim(),
category: form.category.trim(),
iconUrl: form.iconUrl.trim(),
coverUrl: form.coverUrl.trim(),
launchMode: form.launchMode.trim() || "h5_popup",
orientation: form.orientation.trim() || "portrait",
minCoin: Number(form.minCoin || 0),
status: form.status.trim() || "active",
sortOrder: Number(form.sortOrder || 0),
tags: form.tagsText
.split(",")
.map((tag) => tag.trim())
.filter(Boolean),
};
}
function platformPayload(form) {
return {
platformCode: form.platformCode.trim(),
platformName: form.platformName.trim(),
status: form.status.trim() || "active",
apiBaseUrl: form.apiBaseUrl.trim(),
sortOrder: Number(form.sortOrder || 0),
};
}

View File

@ -0,0 +1,440 @@
import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { createOptionsColumnFilter } from "@/shared/ui/tableFilters.js";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { useGamesPage } from "@/features/games/hooks/useGamesPage.js";
import styles from "@/features/games/games.module.css";
const statusOptions = [
["", "全部状态"],
["active", "Active"],
["maintenance", "Maintenance"],
["disabled", "Disabled"],
];
const orientationOptions = [
["portrait", "Portrait"],
["landscape", "Landscape"],
];
const launchModeOptions = [["h5_popup", "H5 弹窗"]];
const baseColumns = [
{
key: "game",
label: "游戏",
width: "minmax(290px, 1.5fr)",
render: (game) => <GameIdentity game={game} />,
},
{
key: "platform",
label: "平台",
width: "minmax(160px, 0.75fr)",
render: (game) => (
<div className={styles.stack}>
<span>{game.platformCode}</span>
<span className={styles.meta}>{game.providerGameId}</span>
</div>
),
},
{
key: "launch",
label: "启动",
width: "minmax(150px, 0.75fr)",
render: (game) => (
<div className={styles.stack}>
<span>{game.launchMode}</span>
<span className={styles.meta}>{game.orientation}</span>
</div>
),
},
{
key: "coin",
label: "最低金币",
width: "minmax(116px, 0.55fr)",
render: (game) => formatNumber(game.minCoin),
},
{
key: "status",
label: "状态",
width: "minmax(120px, 0.6fr)",
},
{
key: "updatedAt",
label: "更新时间",
width: "minmax(170px, 0.9fr)",
render: (game) => formatMillis(game.updatedAtMs || game.createdAtMs),
},
{
key: "actions",
label: "操作",
width: "minmax(76px, 0.4fr)",
},
];
export function GameListPage() {
const page = useGamesPage();
const items = page.data.items || [];
const platformFilterOptions = [["", "全部平台"], ...page.platformOptions];
const columns = baseColumns.map((column) => {
if (column.key === "platform") {
return {
...column,
filter: createOptionsColumnFilter({
options: platformFilterOptions,
placeholder: "搜索平台",
value: page.platformCode,
onChange: page.setPlatformCode,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: statusOptions,
placeholder: "搜索状态",
value: page.status,
onChange: page.setStatus,
}),
render: (game) => <GameStatusSwitch game={game} page={page} />,
};
}
if (column.key === "actions") {
return {
...column,
render: (game) => <GameRowActions game={game} page={page} />,
};
}
return column;
});
return (
<AdminListPage>
<AdminListToolbar
actions={
<>
{page.abilities.canUpdate ? (
<AdminActionIconButton label="添加平台" onClick={page.openCreatePlatform}>
<SportsEsportsOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canCreate ? (
<AdminActionIconButton label="添加游戏" primary onClick={page.openCreateGame}>
<Add fontSize="small" />
</AdminActionIconButton>
) : null}
</>
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={columns}
items={items}
minWidth="1220px"
pagination={{
hasNextPage: Boolean(page.data.nextCursor),
itemCount: items.length,
onPageChange: page.changePage,
onPageSizeChange: page.changePageSize,
page: page.page,
pageSize: page.pageSize,
pageSizeOptions: [20, 50, 100],
}}
rowKey={(game) => game.gameId}
/>
</AdminListBody>
</DataState>
<GameFormDialog
form={page.gameForm}
loading={page.loadingAction === page.activeAction}
mode={page.activeAction === "edit-game" ? "edit" : "create"}
open={page.activeAction === "create-game" || page.activeAction === "edit-game"}
page={page}
onClose={page.closeAction}
onSubmit={page.submitGame}
/>
<PlatformFormDialog
form={page.platformForm}
loading={page.loadingAction === page.activeAction}
mode={page.activeAction === "edit-platform" ? "edit" : "create"}
open={page.activeAction === "create-platform" || page.activeAction === "edit-platform"}
page={page}
onClose={page.closeAction}
onSubmit={page.submitPlatform}
/>
</AdminListPage>
);
}
function GameIdentity({ game }) {
return (
<div className={styles.identity}>
{game.iconUrl ? (
<img alt="" className={styles.icon} src={game.iconUrl} />
) : (
<span className={styles.icon} />
)}
<div className={styles.identityText}>
<span className={styles.name}>{game.gameName || game.gameId}</span>
<span className={styles.meta}>{game.gameId}</span>
<span className={styles.tags}>{(game.tags || []).slice(0, 3).join(" / ")}</span>
</div>
</div>
);
}
function GameStatusSwitch({ page, game }) {
const disabled =
!page.abilities.canStatus || game.status === "disabled" || page.loadingAction === `status-${game.gameId}`;
return (
<AdminSwitch
checked={game.status === "active"}
disabled={disabled}
label={`${game.gameName || game.gameId} 状态`}
size="small"
onChange={(event) => page.changeGameStatus(game, event.target.checked)}
/>
);
}
function GameRowActions({ page, game }) {
return (
<AdminRowActions>
<AdminActionIconButton
disabled={!page.abilities.canUpdate}
label="编辑游戏"
onClick={() => page.openEditGame(game)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}
function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
const disabled = mode === "edit" ? !page.abilities.canUpdate : !page.abilities.canCreate;
return (
<AdminFormDialog
disabled={disabled}
loading={loading}
open={open}
size="wide"
submitLabel={mode === "edit" ? "保存游戏" : "创建游戏"}
title={mode === "edit" ? "编辑游戏" : "添加游戏"}
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="基础信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={mode === "edit"}
label="游戏 ID"
required
value={form.gameId}
onChange={(event) => page.setGameForm({ ...form, gameId: event.target.value })}
/>
<TextField
label="平台"
required
select={page.platformOptions.length > 0}
value={form.platformCode}
onChange={(event) => page.setGameForm({ ...form, platformCode: event.target.value })}
>
{page.platformOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label || value}
</MenuItem>
))}
</TextField>
<TextField
label="平台游戏 ID"
required
value={form.providerGameId}
onChange={(event) => page.setGameForm({ ...form, providerGameId: event.target.value })}
/>
<TextField
label="游戏名称"
required
value={form.gameName}
onChange={(event) => page.setGameForm({ ...form, gameName: event.target.value })}
/>
<TextField
label="类目"
value={form.category}
onChange={(event) => page.setGameForm({ ...form, category: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="启动与状态">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
label="启动模式"
select
value={form.launchMode}
onChange={(event) => page.setGameForm({ ...form, launchMode: event.target.value })}
>
{launchModeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
label="方向"
select
value={form.orientation}
onChange={(event) => page.setGameForm({ ...form, orientation: event.target.value })}
>
{orientationOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
label="最低金币"
type="number"
value={form.minCoin}
onChange={(event) => page.setGameForm({ ...form, minCoin: event.target.value })}
/>
<TextField
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => page.setGameForm({ ...form, sortOrder: event.target.value })}
/>
<SwitchOnlyField
checked={form.status === "active"}
checkedLabel="启用"
disabled={disabled}
label="游戏启用状态"
uncheckedLabel="维护"
onChange={(checked) =>
page.setGameForm({
...form,
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
})
}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="素材与标签">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<UploadField
disabled={disabled || !page.abilities.canUpload}
label="图标"
value={form.iconUrl}
onChange={(iconUrl) => page.setGameForm({ ...form, iconUrl })}
/>
<UploadField
disabled={disabled || !page.abilities.canUpload}
label="封面"
value={form.coverUrl}
onChange={(coverUrl) => page.setGameForm({ ...form, coverUrl })}
/>
<TextField
className={styles.fullField}
label="标签"
value={form.tagsText}
onChange={(event) => page.setGameForm({ ...form, tagsText: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
return (
<AdminFormDialog
disabled={!page.abilities.canUpdate}
loading={loading}
open={open}
submitLabel={mode === "edit" ? "保存平台" : "创建平台"}
title={mode === "edit" ? "编辑平台" : "添加平台"}
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="平台信息">
<AdminFormFieldGrid>
<TextField
disabled={mode === "edit"}
label="平台 Code"
required
value={form.platformCode}
onChange={(event) => page.setPlatformForm({ ...form, platformCode: event.target.value })}
/>
<TextField
label="平台名称"
required
value={form.platformName}
onChange={(event) => page.setPlatformForm({ ...form, platformName: event.target.value })}
/>
<SwitchOnlyField
checked={form.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdate}
label="游戏平台启用状态"
uncheckedLabel="维护"
onChange={(checked) =>
page.setPlatformForm({
...form,
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
})
}
/>
<TextField
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => page.setPlatformForm({ ...form, sortOrder: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="接入配置">
<TextField
className={styles.fullField}
label="H5 Base URL"
required
value={form.apiBaseUrl}
onChange={(event) => page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })}
/>
</AdminFormSection>
</AdminFormDialog>
);
}
function SwitchOnlyField({ checked, checkedLabel, disabled, label, onChange, uncheckedLabel }) {
return (
<span className={styles.switchOnlyField}>
<AdminSwitch
checked={checked}
checkedLabel={checkedLabel}
disabled={disabled}
label={label}
uncheckedLabel={uncheckedLabel}
onChange={(event) => onChange(event.target.checked, event)}
/>
</span>
);
}
function formatNumber(value) {
return Number(value || 0).toLocaleString();
}

View File

@ -0,0 +1,14 @@
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function useGameAbilities() {
const { can } = useAuth();
return {
canCreate: can(PERMISSIONS.gameCreate),
canStatus: can(PERMISSIONS.gameStatus),
canUpdate: can(PERMISSIONS.gameUpdate),
canUpload: can(PERMISSIONS.uploadCreate),
canView: can(PERMISSIONS.gameView),
};
}

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const gameRoutes = [
{
label: "游戏列表",
loader: () => import("./pages/GameListPage.jsx").then((module) => module.GameListPage),
menuCode: MENU_CODES.gameList,
pageKey: "game-list",
path: "/games",
permission: PERMISSIONS.gameView,
},
];

View File

@ -1,4 +1,4 @@
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
export function HostOrgActionModal({
children,
@ -7,6 +7,8 @@ export function HostOrgActionModal({
onClose,
onSubmit,
open,
sectioned = true,
sectionTitle = "表单信息",
size = "compact",
submitLabel = "提交",
submitVariant = "primary",
@ -24,7 +26,7 @@ export function HostOrgActionModal({
onClose={onClose}
onSubmit={onSubmit}
>
{children}
{sectioned ? <AdminFormSection title={sectionTitle}>{children}</AdminFormSection> : children}
</AdminFormDialog>
);
}

View File

@ -1,20 +0,0 @@
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
import styles from "@/features/host-org/host-org.module.css";
export function HostOrgFilters({
extraFilters = [],
onReset,
query,
regionId,
status,
}) {
const hasActiveFilters = [query, regionId, status, ...extraFilters.map((filter) => filter.value)].some(
(value) => String(value || "").trim() !== "",
);
return (
<div className={styles.filters}>
{onReset ? <AdminFilterResetButton disabled={!hasActiveFilters} onClick={onReset} /> : null}
</div>
);
}

View File

@ -1,35 +1,32 @@
import Tooltip from "@mui/material/Tooltip";
import { HostOrgFilters } from "@/features/host-org/components/HostOrgFilters.jsx";
import styles from "@/features/host-org/host-org.module.css";
import { IconButton } from "@/shared/ui/IconButton.jsx";
export function HostOrgToolbar({ actions = [], filters }) {
export function HostOrgToolbar({ actions = [] }) {
if (!actions.length) {
return null;
}
return (
<div className={styles.toolbar}>
<div className={styles.toolbarLeft}>
<HostOrgFilters {...filters} />
<div className={styles.toolbarActions}>
{actions.map((action) => (
<Tooltip arrow key={action.label} title={action.label}>
<span>
<IconButton
className={styles.toolbarAction}
disabled={action.disabled}
label={action.label}
tone={action.tone}
onClick={action.onClick}
sx={action.variant === "primary" ? primaryActionSx : undefined}
>
{action.icon}
</IconButton>
</span>
</Tooltip>
))}
</div>
{actions.length ? (
<div className={styles.toolbarActions}>
{actions.map((action) => (
<Tooltip arrow key={action.label} title={action.label}>
<span>
<IconButton
className={styles.toolbarAction}
disabled={action.disabled}
label={action.label}
tone={action.tone}
onClick={action.onClick}
sx={action.variant === "primary" ? primaryActionSx : undefined}
>
{action.icon}
</IconButton>
</span>
</Tooltip>
))}
</div>
) : null}
</div>
);
}

View File

@ -116,6 +116,19 @@ export function useHostAgenciesPage() {
});
};
const closeAgencyFromList = async (agency) => {
if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") {
return;
}
await runAction(`agency-status-${agency.agencyId}`, "Agency 已关闭", async () => {
await closeAgency(agency.agencyId, {
commandId: makeCommandId("agency-close"),
reason: "close agency from list switch",
});
await reload();
});
};
const submitJoinEnabled = async (event) => {
event.preventDefault();
await runAction("agency-join", "入会开关已更新", async () => {
@ -149,6 +162,7 @@ export function useHostAgenciesPage() {
changeQuery,
changeRegionId,
changeStatus,
closeAgencyFromList,
closeForm,
data,
error,

View File

@ -15,17 +15,10 @@ import {
import { listAppUsers } from "@/features/app-users/api";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useBDAbilities } from "@/features/host-org/permissions.js";
import { bdStatusSchema, createBDSchema, createBDLeaderSchema } from "@/features/host-org/schema";
import { createBDSchema, createBDLeaderSchema } from "@/features/host-org/schema";
const emptyBDLeaderForm = () => ({ commandId: makeCommandId("bd-leader"), reason: "", regionId: "", targetUserId: "" });
const emptyBDForm = () => ({ commandId: makeCommandId("bd"), parentLeaderUserId: "", reason: "", targetUserId: "" });
const emptyBDStatusForm = (targetType = "bd") => ({
commandId: makeCommandId("bd-status"),
reason: "",
status: "active",
targetType,
targetUserId: "",
});
const pageSize = 10;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyLookup = () => ({ error: "", loading: false, user: null });
@ -42,7 +35,6 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
const [page, setPage] = useState(1);
const [bdLeaderForm, setBDLeaderForm] = useState(emptyBDLeaderForm);
const [bdForm, setBDForm] = useState(emptyBDForm);
const [bdStatusForm, setBDStatusForm] = useState(() => emptyBDStatusForm(profileType));
const [bdUserLookup, setBDUserLookup] = useState(emptyLookup);
const [leaderBDs, setLeaderBDs] = useState(emptyData);
const [leaderBDsError, setLeaderBDsError] = useState("");
@ -194,22 +186,6 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
});
};
const submitBDStatus = async (event) => {
event.preventDefault();
await runAction("bd-status", profileType === "leader" ? "BD Leader 状态已更新" : "BD 状态已更新", async () => {
const payload = parseForm(bdStatusSchema, { ...bdStatusForm, targetType: profileType });
const { targetType, targetUserId, ...body } = payload;
const data =
targetType === "leader"
? await setBDLeaderStatus(targetUserId, body)
: await setBDStatus(targetUserId, body);
setBDStatusForm(emptyBDStatusForm(profileType));
setActiveAction("");
await reload();
return data;
});
};
const toggleBDLeader = async (leader, enabled) => {
const status = enabled ? "active" : "disabled";
await runAction(
@ -227,6 +203,19 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
);
};
const toggleBD = async (bd, enabled) => {
const status = enabled ? "active" : "disabled";
await runAction(`bd-status-${bd.userId}`, enabled ? "BD 已启用" : "BD 已停用", async () => {
const data = await setBDStatus(bd.userId, {
commandId: makeCommandId("bd-status"),
reason: "",
status,
});
await reload();
return data;
});
};
const removeBDLeader = async (leader) => {
const ok = await confirm({
confirmText: "删除",
@ -284,7 +273,6 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
activeAction,
bdForm,
bdLeaderForm,
bdStatusForm,
bdUserLookup,
changeParentLeaderUserId,
changeQuery,
@ -312,18 +300,16 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
openAddLeaderBD,
openBDForm: () => setActiveAction("bd"),
openBDLeaderForm: () => setActiveAction("bd-leader"),
openBDStatusForm: () => setActiveAction("bd-status"),
openLeaderBDs,
selectedLeader,
setBDForm,
setBDLeaderForm,
setBDStatusForm,
setPage,
status,
submitBD,
submitBDLeader,
submitLeaderBD,
submitBDStatus,
toggleBD,
toggleBDLeader,
};
}

View File

@ -0,0 +1,123 @@
import { useMemo, useState } from "react";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { closeAgency, listAgencies } from "@/features/host-org/api";
import { useAgencyAbilities } from "@/features/host-org/permissions.js";
const pageSize = 10;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
export function useHostManagersPage() {
const abilities = useAgencyAbilities();
const { showToast } = useToast();
const { loadingRegions, regionOptions } = useRegionOptions();
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [regionId, setRegionId] = useState("");
const [parentBdUserId, setParentBdUserId] = useState("");
const [page, setPage] = useState(1);
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(
() => ({
keyword: query,
parent_bd_user_id: parentBdUserId,
region_id: regionId,
status,
}),
[parentBdUserId, query, regionId, status],
);
const {
data = emptyData,
error,
loading,
reload,
} = usePaginatedQuery({
errorMessage: "加载经理列表失败",
fetcher: listAgencies,
filters,
page,
pageSize,
queryKey: ["host-org", "managers", filters, page],
});
const changeQuery = (value) => {
setQuery(value);
setPage(1);
};
const changeStatus = (value) => {
setStatus(value);
setPage(1);
};
const changeRegionId = (value) => {
setRegionId(value);
setPage(1);
};
const changeParentBdUserId = (value) => {
setParentBdUserId(value);
setPage(1);
};
const resetFilters = () => {
setQuery("");
setStatus("");
setRegionId("");
setParentBdUserId("");
setPage(1);
};
const closeManagerAgency = async (agency) => {
if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") {
return;
}
await runAction(`manager-status-${agency.agencyId}`, "经理已停用", async () => {
await closeAgency(agency.agencyId, {
commandId: makeCommandId("manager-close"),
reason: "close manager agency from list switch",
});
await reload();
});
};
const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action);
try {
await submitter();
showToast(successMessage, "success");
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
changeParentBdUserId,
changeQuery,
changeRegionId,
changeStatus,
closeManagerAgency,
data,
error,
loading,
loadingAction,
loadingRegions,
page,
parentBdUserId,
query,
regionId,
regionOptions,
reload,
resetFilters,
setPage,
status,
};
}
function makeCommandId(prefix) {
return `${prefix}-${Date.now()}`;
}

View File

@ -63,6 +63,7 @@
flex: 0 0 auto;
align-items: center;
gap: var(--space-2);
margin-left: auto;
}
.filters {

View File

@ -1,16 +1,13 @@
import Add from "@mui/icons-material/Add";
import BlockOutlined from "@mui/icons-material/BlockOutlined";
import ToggleOnOutlined from "@mui/icons-material/ToggleOnOutlined";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { agencyStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
import { useHostAgenciesPage } from "@/features/host-org/hooks/useHostAgenciesPage.js";
@ -45,7 +42,7 @@ const agencyColumns = [
{
key: "status",
label: "状态",
render: (item) => <HostOrgStatus value={item.status} />,
render: (item, _index, context) => <AgencyStatusSwitch item={item} page={context?.page} />,
width: "minmax(90px, 0.7fr)",
},
{
@ -129,46 +126,27 @@ export function HostAgenciesPage() {
<div className={styles.contentPanel}>
<HostOrgToolbar
actions={toolbarActions}
filters={{
extraFilters: [
{
label: "上级 BD 用户 ID",
name: "parentBdUserId",
onChange: page.changeParentBdUserId,
value: page.parentBdUserId,
},
],
loadingRegions: page.loadingRegions,
query: page.query,
regionId: page.regionId,
regionOptions: page.regionOptions,
searchPlaceholder: "搜索 Agency、Owner、用户...",
status: page.status,
statusOptions: agencyStatusFilters,
onQueryChange: page.changeQuery,
onRegionIdChange: page.changeRegionId,
onReset: page.resetFilters,
onStatusChange: page.changeStatus,
}}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}>
<HostOrgTable
columns={columns}
context={{ regionOptions: page.regionOptions }}
context={{ page, regionOptions: page.regionOptions }}
items={items}
minWidth="1250px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(item) => item.agencyId}
/>
{total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</div>
</DataState>
</div>
@ -177,6 +155,7 @@ export function HostAgenciesPage() {
disabled={createDisabled}
loading={page.loadingAction === "agency"}
open={page.activeAction === "agency"}
sectionTitle="Agency 信息"
onClose={page.closeAction}
onSubmit={page.submitAgency}
title="创建 Agency"
@ -211,17 +190,15 @@ export function HostAgenciesPage() {
value={page.agencyForm.maxHosts}
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, maxHosts: event.target.value })}
/>
<FormControlLabel
control={
<Switch
checked={page.agencyForm.joinEnabled}
disabled={createDisabled}
onChange={(event) =>
page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })
}
/>
<AdminSwitch
checked={page.agencyForm.joinEnabled}
checkedLabel="允许"
disabled={createDisabled}
label="入会状态"
uncheckedLabel="禁止"
onChange={(event) =>
page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })
}
label={page.agencyForm.joinEnabled ? "允许入会" : "禁止入会"}
/>
<TextField
disabled={createDisabled}
@ -237,6 +214,7 @@ export function HostAgenciesPage() {
disabled={statusDisabled}
loading={page.loadingAction === "agency-join"}
open={page.activeAction === "agency-join"}
sectionTitle="入会设置"
onClose={page.closeAction}
onSubmit={page.submitJoinEnabled}
title="入会开关"
@ -251,17 +229,15 @@ export function HostAgenciesPage() {
page.setJoinEnabledForm({ ...page.joinEnabledForm, agencyId: event.target.value })
}
/>
<FormControlLabel
control={
<Switch
checked={page.joinEnabledForm.joinEnabled}
disabled={statusDisabled}
onChange={(event) =>
page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked })
}
/>
<AdminSwitch
checked={page.joinEnabledForm.joinEnabled}
checkedLabel="允许"
disabled={statusDisabled}
label="入会状态"
uncheckedLabel="禁止"
onChange={(event) =>
page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked })
}
label={page.joinEnabledForm.joinEnabled ? "允许入会" : "禁止入会"}
/>
<TextField
disabled={statusDisabled}
@ -279,6 +255,7 @@ export function HostAgenciesPage() {
disabled={statusDisabled}
loading={page.loadingAction === "agency-close"}
open={page.activeAction === "agency-close"}
sectionTitle="关闭信息"
onClose={page.closeAction}
onSubmit={page.submitClose}
submitLabel="确认关闭"
@ -328,3 +305,19 @@ function ParentBD({ item }) {
/>
);
}
function AgencyStatusSwitch({ item, page }) {
const active = item.status === "active";
return (
<AdminSwitch
checked={active}
disabled={!active || !page?.abilities.canStatus || page.loadingAction === `agency-status-${item.agencyId}`}
inputProps={{ "aria-label": active ? "关闭 Agency" : "Agency 已关闭" }}
onChange={(event) => {
if (!event.target.checked) {
page.closeAgencyFromList(item);
}
}}
/>
);
}

View File

@ -1,14 +1,12 @@
import Add from "@mui/icons-material/Add";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import PersonAddAlt1Outlined from "@mui/icons-material/PersonAddAlt1Outlined";
import Switch from "@mui/material/Switch";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { bdStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
@ -88,19 +86,6 @@ export function HostBdLeadersPage() {
<div className={styles.contentPanel}>
<HostOrgToolbar
actions={toolbarActions}
filters={{
loadingRegions: page.loadingRegions,
query: page.query,
regionId: page.regionId,
regionOptions: page.regionOptions,
searchPlaceholder: "搜索用户 ID、展示 ID、用户名...",
status: page.status,
statusOptions: bdStatusFilters,
onQueryChange: page.changeQuery,
onRegionIdChange: page.changeRegionId,
onReset: page.resetFilters,
onStatusChange: page.changeStatus,
}}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
@ -110,16 +95,18 @@ export function HostBdLeadersPage() {
context={{ page, regionOptions: page.regionOptions }}
items={items}
minWidth="1120px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(item) => `bd-leader-${item.userId}`}
/>
{total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</div>
</DataState>
</div>
@ -128,6 +115,7 @@ export function HostBdLeadersPage() {
disabled={createDisabled}
loading={page.loadingAction === "bd-leader"}
open={page.activeAction === "bd-leader"}
sectionTitle="Leader 信息"
onClose={page.closeAction}
onSubmit={page.submitBDLeader}
title="创建 BD Leader"
@ -163,6 +151,7 @@ export function HostBdLeadersPage() {
disabled={createDisabled}
loading={page.loadingAction === "leader-add-bd"}
open={page.activeAction === "leader-add-bd"}
sectionTitle="下级 BD 信息"
onClose={page.closeAction}
onSubmit={page.submitLeaderBD}
title="增加下级 BD"
@ -258,7 +247,7 @@ function CreatorIdentity({ item }) {
function BDLeaderStatusSwitch({ item, page }) {
return (
<Switch
<AdminSwitch
checked={item.status === "active"}
disabled={!page?.abilities.canUpdate || page.loadingAction === `bd-leader-status-${item.userId}`}
inputProps={{ "aria-label": item.status === "active" ? "停用 BD Leader" : "启用 BD Leader" }}

View File

@ -1,14 +1,11 @@
import Add from "@mui/icons-material/Add";
import SyncAltOutlined from "@mui/icons-material/SyncAltOutlined";
import MenuItem from "@mui/material/MenuItem";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { bdStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
@ -38,7 +35,7 @@ const bdColumns = [
{
key: "status",
label: "状态",
render: (item) => <HostOrgStatus value={item.status} />,
render: (item, _index, context) => <BDStatusSwitch item={item} page={context?.page} />,
width: "minmax(90px, 0.7fr)",
},
{
@ -64,13 +61,9 @@ const bdColumns = [
export function HostBdsPage() {
const page = useHostBdsPage({ profileType: "bd" });
const createDisabled = !page.abilities.canCreate;
const updateDisabled = !page.abilities.canUpdate;
const items = page.data.items || [];
const total = page.data.total || 0;
const toolbarActions = [
page.abilities.canUpdate
? { icon: <SyncAltOutlined fontSize="small" />, label: "BD 状态", onClick: page.openBDStatusForm }
: null,
page.abilities.canCreate
? { icon: <Add fontSize="small" />, label: "创建 BD", onClick: page.openBDForm, variant: "primary" }
: null,
@ -115,46 +108,27 @@ export function HostBdsPage() {
<div className={styles.contentPanel}>
<HostOrgToolbar
actions={toolbarActions}
filters={{
extraFilters: [
{
label: "上级 Leader 用户 ID",
name: "parentLeaderUserId",
onChange: page.changeParentLeaderUserId,
value: page.parentLeaderUserId,
},
],
loadingRegions: page.loadingRegions,
query: page.query,
regionId: page.regionId,
regionOptions: page.regionOptions,
searchPlaceholder: "搜索用户 ID、展示 ID、用户名...",
status: page.status,
statusOptions: bdStatusFilters,
onQueryChange: page.changeQuery,
onRegionIdChange: page.changeRegionId,
onReset: page.resetFilters,
onStatusChange: page.changeStatus,
}}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}>
<HostOrgTable
columns={columns}
context={{ regionOptions: page.regionOptions }}
context={{ page, regionOptions: page.regionOptions }}
items={items}
minWidth="1260px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(item) => `bd-${item.userId}`}
/>
{total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</div>
</DataState>
</div>
@ -163,6 +137,7 @@ export function HostBdsPage() {
disabled={createDisabled}
loading={page.loadingAction === "bd"}
open={page.activeAction === "bd"}
sectionTitle="BD 信息"
onClose={page.closeAction}
onSubmit={page.submitBD}
title="创建 BD"
@ -192,45 +167,6 @@ export function HostBdsPage() {
onChange={(event) => page.setBDForm({ ...page.bdForm, reason: event.target.value })}
/>
</HostOrgActionModal>
<HostOrgActionModal
disabled={updateDisabled}
loading={page.loadingAction === "bd-status"}
open={page.activeAction === "bd-status"}
onClose={page.closeAction}
onSubmit={page.submitBDStatus}
title="BD 状态"
>
<TextField
disabled={updateDisabled}
label="用户 ID"
required
type="number"
value={page.bdStatusForm.targetUserId}
onChange={(event) =>
page.setBDStatusForm({ ...page.bdStatusForm, targetUserId: event.target.value })
}
/>
<TextField
disabled={updateDisabled}
label="状态"
required
select
value={page.bdStatusForm.status}
onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, status: event.target.value })}
>
<MenuItem value="active">启用</MenuItem>
<MenuItem value="disabled">停用</MenuItem>
</TextField>
<TextField
disabled={updateDisabled}
label="原因"
multiline
minRows={2}
value={page.bdStatusForm.reason}
onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, reason: event.target.value })}
/>
</HostOrgActionModal>
</section>
);
}
@ -260,6 +196,17 @@ function ParentLeader({ item }) {
);
}
function BDStatusSwitch({ item, page }) {
return (
<AdminSwitch
checked={item.status === "active"}
disabled={!page?.abilities.canUpdate || page.loadingAction === `bd-status-${item.userId}`}
inputProps={{ "aria-label": item.status === "active" ? "停用 BD" : "启用 BD" }}
onChange={(event) => page.toggleBD(item, event.target.checked)}
/>
);
}
function Creator({ item }) {
if (!item.createdByUserId && !item.createdByDisplayUserId && !item.createdByUsername) {
return "-";

View File

@ -1,7 +1,7 @@
import Add from "@mui/icons-material/Add";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import MenuItem from "@mui/material/MenuItem";
import Switch from "@mui/material/Switch";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import {
AdminFormAmountField,
@ -11,7 +11,6 @@ import {
} from "@/shared/ui/AdminFormDialog.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
@ -131,19 +130,6 @@ export function HostCoinSellersPage() {
<div className={styles.contentPanel}>
<HostOrgToolbar
actions={toolbarActions}
filters={{
loadingRegions: page.loadingRegions,
query: page.query,
regionId: page.regionId,
regionOptions: page.regionOptions,
searchPlaceholder: "搜索用户 ID、展示 ID、用户名...",
status: page.status,
statusOptions: coinSellerStatusFilters,
onQueryChange: page.changeQuery,
onRegionIdChange: page.changeRegionId,
onReset: page.resetFilters,
onStatusChange: page.changeStatus,
}}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
@ -153,17 +139,19 @@ export function HostCoinSellersPage() {
context={{ page, regionOptions: page.regionOptions }}
items={items}
minWidth="1040px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
regionFilter={regionFilter}
rowKey={(item) => item.userId}
/>
{total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</div>
</DataState>
</div>
@ -172,6 +160,7 @@ export function HostCoinSellersPage() {
disabled={createDisabled}
loading={page.loadingAction === "coin-seller-create"}
open={page.activeAction === "create"}
sectionTitle="币商信息"
onClose={page.closeAction}
onSubmit={page.submitCreateSeller}
title="添加币商"
@ -196,6 +185,7 @@ export function HostCoinSellersPage() {
disabled={updateDisabled}
loading={String(page.loadingAction).startsWith("coin-seller-edit")}
open={page.activeAction === "edit"}
sectionTitle="联系方式"
onClose={page.closeAction}
onSubmit={page.submitEditSeller}
title="编辑联系方式"
@ -213,6 +203,7 @@ export function HostCoinSellersPage() {
disabled={!page.abilities.canStockCredit || page.selectedSeller?.status !== "active"}
loading={String(page.loadingAction).startsWith("coin-seller-stock")}
open={page.activeAction === "stock"}
sectioned={false}
size="standard"
submitLabel="确认入账"
onClose={page.closeAction}
@ -308,7 +299,7 @@ function SellerIdentity({ item }) {
function SellerStatusSwitch({ item, page }) {
return (
<Switch
<AdminSwitch
checked={item.status === "active"}
disabled={!page.abilities.canUpdate || page.loadingAction === `coin-seller-status-${item.userId}`}
inputProps={{ "aria-label": item.status === "active" ? "停用币商" : "启用币商" }}

View File

@ -1,13 +1,11 @@
import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
@ -88,9 +86,6 @@ export function HostCountriesPage() {
return (
<AdminListPage>
<AdminListToolbar
filters={
<AdminFilterResetButton disabled={!page.query && !page.enabledStatus} onClick={page.resetFilters} />
}
actions={
page.abilities.canCreate ? (
<AdminActionIconButton label="创建国家" primary onClick={page.openCreateCountry}>
@ -113,6 +108,7 @@ export function HostCountriesPage() {
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
loading={page.loadingAction === "country-create" || page.loadingAction === "country-edit"}
open={page.activeAction === "create" || page.activeAction === "edit"}
sectionTitle="国家信息"
onClose={page.closeAction}
onSubmit={page.submitCountry}
title={page.activeAction === "edit" ? "编辑国家" : "创建国家"}
@ -174,17 +170,15 @@ export function HostCountriesPage() {
onChange={(event) => page.setCountryForm({ ...page.countryForm, sortOrder: event.target.value })}
/>
{page.activeAction === "create" ? (
<FormControlLabel
control={
<Switch
checked={page.countryForm.enabled}
disabled={createDisabled}
onChange={(event) =>
page.setCountryForm({ ...page.countryForm, enabled: event.target.checked })
}
/>
<AdminSwitch
checked={page.countryForm.enabled}
checkedLabel="启用"
disabled={createDisabled}
label="国家状态"
uncheckedLabel="停用"
onChange={(event) =>
page.setCountryForm({ ...page.countryForm, enabled: event.target.checked })
}
label={page.countryForm.enabled ? "启用" : "停用"}
/>
) : null}
</HostOrgActionModal>
@ -206,7 +200,7 @@ function CountryActions({ item, page }) {
function CountryStatusSwitch({ item, page }) {
return (
<Switch
<AdminSwitch
checked={Boolean(item.enabled)}
disabled={!page.abilities.canStatus || page.loadingAction === `country-status-${item.countryId}`}
inputProps={{ "aria-label": item.enabled ? "禁用国家" : "启用国家" }}

View File

@ -1,11 +1,9 @@
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
import { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import styles from "@/features/host-org/host-org.module.css";
@ -20,7 +18,7 @@ const hostColumns = [
{
key: "status",
label: "状态",
render: (item) => <HostOrgStatus value={item.status} />,
render: (item) => <HostStatusSwitch item={item} />,
width: "minmax(90px, 0.7fr)",
},
{
@ -110,30 +108,6 @@ export function HostHostsPage() {
return (
<section className={styles.root}>
<div className={styles.contentPanel}>
<HostOrgToolbar
filters={{
extraFilters: [
{
label: "Agency ID",
name: "agencyId",
onChange: page.changeAgencyId,
value: page.agencyId,
},
],
loadingRegions: page.loadingRegions,
query: page.query,
regionId: page.regionId,
regionOptions: page.regionOptions,
searchPlaceholder: "搜索用户 ID、展示 ID、用户名、Agency...",
status: page.status,
statusOptions: hostStatusFilters,
onQueryChange: page.changeQuery,
onRegionIdChange: page.changeRegionId,
onReset: page.resetFilters,
onStatusChange: page.changeStatus,
}}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}>
<HostOrgTable
@ -141,16 +115,18 @@ export function HostHostsPage() {
context={{ regionOptions: page.regionOptions }}
items={items}
minWidth="1240px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(item) => item.userId}
/>
{total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</div>
</DataState>
</div>
@ -168,3 +144,13 @@ function HostUser({ item }) {
/>
);
}
function HostStatusSwitch({ item }) {
return (
<AdminSwitch
checked={item.status === "active"}
disabled
inputProps={{ "aria-label": item.status === "active" ? "主播已启用" : "主播已停用" }}
/>
);
}

View File

@ -0,0 +1,168 @@
import { formatMillis } from "@/shared/utils/time.js";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { agencyStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { useHostManagersPage } from "@/features/host-org/hooks/useHostManagersPage.js";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import styles from "@/features/host-org/host-org.module.css";
const managerColumns = [
{
key: "ownerUserId",
label: "经理",
render: (item) => <ManagerUser item={item} />,
width: "minmax(220px, 1.25fr)",
},
{
key: "agency",
label: "Agency",
render: (item) => <HostOrgEntity id={item.agencyId} name={item.name} prefix="Agency " />,
width: "minmax(190px, 1fr)",
},
{
key: "parentBdUserId",
label: "上级 BD",
render: (item) => <ParentBD item={item} />,
width: "minmax(220px, 1.2fr)",
},
{
key: "regionId",
label: "区域",
render: (item, _index, context) => hostOrgRegionName(item, context?.regionOptions),
width: "minmax(130px, 0.75fr)",
},
{
key: "status",
label: "状态",
render: (item, _index, context) => <ManagerStatusSwitch item={item} page={context?.page} />,
width: "minmax(90px, 0.7fr)",
},
{
key: "joinEnabled",
label: "入会",
render: (item) => (
<span className={item.joinEnabled ? styles.positive : styles.negative}>
{item.joinEnabled ? "允许" : "禁止"}
</span>
),
width: "minmax(80px, 0.6fr)",
},
{ key: "maxHosts", label: "最大主播数", width: "minmax(100px, 0.7fr)" },
{
key: "updatedAtMs",
label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(170px, 1fr)",
},
];
export function HostManagersPage() {
const page = useHostManagersPage();
const items = page.data.items || [];
const total = page.data.total || 0;
const columns = managerColumns.map((column) => {
if (column.key === "ownerUserId") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索经理、Agency、用户",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "parentBdUserId") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索上级 BD 用户 ID",
value: page.parentBdUserId,
onChange: page.changeParentBdUserId,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: agencyStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
};
}
return column;
});
return (
<section className={styles.root}>
<div className={styles.contentPanel}>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}>
<HostOrgTable
columns={columns}
context={{ page, regionOptions: page.regionOptions }}
items={items}
minWidth="1250px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(item) => `manager-${item.ownerUserId || item.agencyId}`}
/>
</div>
</DataState>
</div>
</section>
);
}
function ManagerUser({ item }) {
return (
<HostOrgPerson
avatar={item.ownerAvatar}
displayUserId={item.ownerDisplayUserId}
fallbackId={item.ownerUserId}
username={item.ownerUsername}
/>
);
}
function ParentBD({ item }) {
if (!item.parentBdUserId) {
return "-";
}
return (
<HostOrgPerson
avatar={item.parentBdAvatar}
displayUserId={item.parentBdDisplayUserId}
fallbackId={item.parentBdUserId}
username={item.parentBdUsername || "BD"}
/>
);
}
function ManagerStatusSwitch({ item, page }) {
const active = item.status === "active";
return (
<AdminSwitch
checked={active}
disabled={!active || !page?.abilities.canStatus || page.loadingAction === `manager-status-${item.agencyId}`}
inputProps={{ "aria-label": active ? "停用经理" : "经理已停用" }}
onChange={(event) => {
if (!event.target.checked) {
page.closeManagerAgency(item);
}
}}
/>
);
}

View File

@ -1,11 +1,10 @@
import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
import Switch from "@mui/material/Switch";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
import { regionStatusFilters } from "@/features/host-org/constants.js";
@ -81,17 +80,8 @@ export function HostRegionsPage() {
return (
<section className={styles.root}>
<div className={styles.contentPanel}>
<div className={styles.toolbar}>
<div className={styles.toolbarLeft}>
<section className={styles.filters}>
<AdminFilterResetButton
disabled={!page.query && !page.status}
onClick={page.resetFilters}
/>
</section>
</div>
{page.abilities.canCreate ? (
{page.abilities.canCreate ? (
<div className={styles.toolbar}>
<div className={styles.toolbarActions}>
<Tooltip arrow title="创建区域">
<span>
@ -101,8 +91,8 @@ export function HostRegionsPage() {
</span>
</Tooltip>
</div>
) : null}
</div>
</div>
) : null}
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}>
@ -118,6 +108,7 @@ export function HostRegionsPage() {
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
loading={page.loadingAction === "region-create" || page.loadingAction === "region-edit"}
open={page.activeAction === "create" || page.activeAction === "edit"}
sectionTitle="区域信息"
onClose={page.closeAction}
onSubmit={page.submitRegion}
title={page.activeAction === "edit" ? "编辑区域" : "创建区域"}
@ -182,7 +173,7 @@ function RegionStatusSwitch({ item, page }) {
const checked = item.status === "active";
const isGlobal = item.regionCode === "GLOBAL";
return (
<Switch
<AdminSwitch
checked={checked}
disabled={isGlobal || !page.abilities.canStatus || page.loadingAction === `region-status-${item.regionId}`}
inputProps={{ "aria-label": checked ? "停用区域" : "启用区域" }}

View File

@ -18,7 +18,15 @@ export const hostOrgRoutes = [
permission: PERMISSIONS.regionView
},
{
label: "Agency 管理",
label: "经理列表",
loader: () => import("./pages/HostManagersPage.jsx").then((module) => module.HostManagersPage),
menuCode: MENU_CODES.hostOrgManagers,
pageKey: "host-org-managers",
path: "/host/managers",
permission: PERMISSIONS.agencyView
},
{
label: "Agency 列表",
loader: () => import("./pages/HostAgenciesPage.jsx").then((module) => module.HostAgenciesPage),
menuCode: MENU_CODES.hostOrgAgencies,
pageKey: "host-org-agencies",
@ -26,7 +34,7 @@ export const hostOrgRoutes = [
permission: PERMISSIONS.agencyView
},
{
label: "BD Leader 管理",
label: "BD Leader 列表",
loader: () => import("./pages/HostBdLeadersPage.jsx").then((module) => module.HostBdLeadersPage),
menuCode: MENU_CODES.hostOrgBdLeaders,
pageKey: "host-org-bd-leaders",
@ -34,7 +42,7 @@ export const hostOrgRoutes = [
permission: PERMISSIONS.bdView
},
{
label: "BD 管理",
label: "BD 列表",
loader: () => import("./pages/HostBdsPage.jsx").then((module) => module.HostBdsPage),
menuCode: MENU_CODES.hostOrgBds,
pageKey: "host-org-bds",
@ -42,7 +50,7 @@ export const hostOrgRoutes = [
permission: PERMISSIONS.bdView
},
{
label: "主播管理",
label: "主播列表",
loader: () => import("./pages/HostHostsPage.jsx").then((module) => module.HostHostsPage),
menuCode: MENU_CODES.hostOrgHosts,
pageKey: "host-org-hosts",
@ -50,7 +58,7 @@ export const hostOrgRoutes = [
permission: PERMISSIONS.hostView
},
{
label: "币商管理",
label: "币商列表",
loader: () => import("./pages/HostCoinSellersPage.jsx").then((module) => module.HostCoinSellersPage),
menuCode: MENU_CODES.hostOrgCoinSellers,
pageKey: "host-org-coin-sellers",

View File

@ -1,9 +0,0 @@
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
export function LogFilters({ onReset, query }) {
return (
<section className="filters-panel">
<AdminFilterResetButton disabled={!query} onClick={onReset} />
</section>
);
}

View File

@ -1,7 +1,7 @@
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
export function LogTable({ data, isLogin, onQueryChange, query }) {
export function LogTable({ data, isLogin, onQueryChange, pagination, query }) {
const items = data.items || [];
const columns = isLogin
? [
@ -68,9 +68,8 @@ export function LogTable({ data, isLogin, onQueryChange, query }) {
columns={columns}
items={items}
minWidth={isLogin ? "920px" : "1080px"}
pagination={pagination}
rowKey={(item) => item.id}
title={isLogin ? "登录记录" : "操作记录"}
total={data.total}
/>
);
}

View File

@ -1,6 +1,4 @@
import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { LogFilters } from "@/features/logs/components/LogFilters.jsx";
import { LogTable } from "@/features/logs/components/LogTable.jsx";
import { LogToolbar } from "@/features/logs/components/LogToolbar.jsx";
import { useLogsPage } from "@/features/logs/hooks/useLogsPage.js";
@ -11,15 +9,19 @@ export function LogsPage({ type }) {
return (
<>
<LogToolbar canExport={page.abilities.canExport} isLogin={page.isLogin} onExport={page.downloadLogs} />
<LogFilters query={page.query} onReset={page.resetFilters} />
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<LogTable data={page.data} isLogin={page.isLogin} query={page.query} onQueryChange={page.changeQuery} />
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={page.data.total || 0}
onPageChange={page.setPage}
<LogTable
data={page.data}
isLogin={page.isLogin}
pagination={{
page: page.page,
pageSize: page.data.pageSize || 10,
total: page.data.total || 0,
onPageChange: page.setPage,
}}
query={page.query}
onQueryChange={page.changeQuery}
/>
</DataState>
</>

View File

@ -1,6 +1,5 @@
import Drawer from "@mui/material/Drawer";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import { Button } from "@/shared/ui/Button.jsx";
@ -9,17 +8,30 @@ export function MenuFormDrawer({ editingMenu, form, onClose, onSubmit, open, set
<Drawer anchor="right" open={open} onClose={onClose}>
<form className="form-drawer" onSubmit={onSubmit}>
<h2>{editingMenu ? "编辑菜单" : "新增菜单"}</h2>
<TextField label="菜单名称" required value={form.label} onChange={(event) => setForm({ ...form, label: event.target.value })} />
<TextField label="菜单编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
<TextField label="父级 ID" type="number" value={form.parentId} onChange={(event) => setForm({ ...form, parentId: event.target.value })} />
<TextField label="路由" value={form.path} onChange={(event) => setForm({ ...form, path: event.target.value })} />
<TextField label="图标" value={form.icon} onChange={(event) => setForm({ ...form, icon: event.target.value })} />
<TextField label="权限码" value={form.permissionCode} onChange={(event) => setForm({ ...form, permissionCode: event.target.value })} />
<TextField label="排序" type="number" value={form.sort} onChange={(event) => setForm({ ...form, sort: event.target.value })} />
<FormControlLabel
control={<Switch checked={form.visible} onChange={(event) => setForm({ ...form, visible: event.target.checked })} />}
label={form.visible ? "显示" : "隐藏"}
/>
<section className="form-drawer__section">
<div className="form-drawer__section-title">基础信息</div>
<div className="form-drawer__grid">
<TextField label="菜单名称" required value={form.label} onChange={(event) => setForm({ ...form, label: event.target.value })} />
<TextField label="菜单编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
<TextField label="父级 ID" type="number" value={form.parentId} onChange={(event) => setForm({ ...form, parentId: event.target.value })} />
<TextField label="排序" type="number" value={form.sort} onChange={(event) => setForm({ ...form, sort: event.target.value })} />
<AdminSwitch
checked={form.visible}
checkedLabel="显示"
label="菜单显示状态"
uncheckedLabel="隐藏"
onChange={(event) => setForm({ ...form, visible: event.target.checked })}
/>
</div>
</section>
<section className="form-drawer__section">
<div className="form-drawer__section-title">路由权限</div>
<div className="form-drawer__grid">
<TextField label="路由" value={form.path} onChange={(event) => setForm({ ...form, path: event.target.value })} />
<TextField label="图标" value={form.icon} onChange={(event) => setForm({ ...form, icon: event.target.value })} />
<TextField label="权限码" value={form.permissionCode} onChange={(event) => setForm({ ...form, permissionCode: event.target.value })} />
</div>
</section>
<div className="form-drawer__actions">
<Button onClick={onClose}>取消</Button>
<Button type="submit" variant="primary">提交</Button>

View File

@ -9,14 +9,22 @@ export function PermissionFormDrawer({ editingPermission, form, onClose, onSubmi
<Drawer anchor="right" open={open} onClose={onClose}>
<form className="form-drawer" onSubmit={onSubmit}>
<h2>{editingPermission ? "编辑权限" : "新增权限"}</h2>
<TextField label="权限名称" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
<TextField label="权限编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
<TextField label="类型" required select value={form.kind} onChange={(event) => setForm({ ...form, kind: event.target.value })}>
{permissionKinds.map(([value, label]) => (
<MenuItem key={value} value={value}>{label}</MenuItem>
))}
</TextField>
<TextField label="说明" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
<section className="form-drawer__section">
<div className="form-drawer__section-title">基础信息</div>
<div className="form-drawer__grid">
<TextField label="权限名称" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
<TextField label="权限编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
<TextField label="类型" required select value={form.kind} onChange={(event) => setForm({ ...form, kind: event.target.value })}>
{permissionKinds.map(([value, label]) => (
<MenuItem key={value} value={value}>{label}</MenuItem>
))}
</TextField>
</div>
</section>
<section className="form-drawer__section">
<div className="form-drawer__section-title">说明</div>
<TextField label="说明" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
</section>
<div className="form-drawer__actions">
<Button onClick={onClose}>取消</Button>
<Button type="submit" variant="primary">提交</Button>

View File

@ -3,7 +3,6 @@ import SyncOutlined from "@mui/icons-material/SyncOutlined";
import { DataState } from "@/shared/ui/DataState.jsx";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
@ -21,9 +20,6 @@ export function MenuManagementPage() {
<>
<AdminListPage>
<AdminListToolbar
filters={
<AdminFilterResetButton disabled={!page.query} onClick={page.resetFilters} />
}
actions={
<>
{page.abilities.canSyncPermission ? (

View File

@ -1,28 +0,0 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { EntityId, NotificationDto, NotificationListDto } from "@/shared/api/types";
export function listNotifications(): Promise<NotificationListDto> {
return apiRequest<NotificationListDto>(apiEndpointPath(API_OPERATIONS.listNotifications));
}
export function markNotificationRead(id: EntityId): Promise<NotificationDto> {
const endpoint = API_ENDPOINTS.markNotificationRead;
return apiRequest<NotificationDto, Record<string, never>>(apiEndpointPath(API_OPERATIONS.markNotificationRead, { id }), {
body: {},
method: endpoint.method
});
}
export function markAllNotificationsRead(): Promise<unknown> {
const endpoint = API_ENDPOINTS.markAllNotificationsRead;
return apiRequest<unknown, Record<string, never>>(apiEndpointPath(API_OPERATIONS.markAllNotificationsRead), {
body: {},
method: endpoint.method
});
}
export function deleteNotification(id: EntityId): Promise<unknown> {
const endpoint = API_ENDPOINTS.deleteNotification;
return apiRequest(apiEndpointPath(API_OPERATIONS.deleteNotification, { id }), { method: endpoint.method });
}

View File

@ -1,46 +0,0 @@
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import DoneAllOutlined from "@mui/icons-material/DoneAllOutlined";
import { Button } from "@/shared/ui/Button.jsx";
import { Card } from "@/shared/ui/Card.jsx";
export function NotificationList({ abilities, items, onDelete, onRead }) {
return (
<Card className="table-card" component="section">
<div className="notification-list">
{(items || []).map((item) => (
<div className={`notification-item notification-item--${item.level}`} key={item.id}>
<div>
<div className="notification-title">{item.title}</div>
<div className="notification-content">{item.content}</div>
<div className="muted">{formatDate(item.createdAt)}</div>
</div>
<div className="row-actions">
{!item.readAt && abilities.canRead ? (
<Button onClick={() => onRead(item.id)}>
<DoneAllOutlined fontSize="small" />
已读
</Button>
) : (
<span className="muted">{item.readAt ? "已读" : "未读"}</span>
)}
{abilities.canDelete ? (
<Button variant="danger" onClick={() => onDelete(item)}>
<DeleteOutlineOutlined fontSize="small" />
删除
</Button>
) : null}
</div>
</div>
))}
{!items?.length ? <div className="empty-state">暂无通知</div> : null}
</div>
</Card>
);
}
function formatDate(value) {
if (!value) {
return "-";
}
return new Date(value).toLocaleString("zh-CN", { hour12: false });
}

View File

@ -1,16 +0,0 @@
import DoneAllOutlined from "@mui/icons-material/DoneAllOutlined";
import { Button } from "@/shared/ui/Button.jsx";
import { PageHead } from "@/shared/ui/PageHead.jsx";
export function NotificationToolbar({ canReadAll, onReadAll, unread }) {
return (
<PageHead title="通知中心" meta={`未读 ${unread || 0}`}>
{canReadAll ? (
<Button onClick={onReadAll}>
<DoneAllOutlined fontSize="small" />
全部已读
</Button>
) : null}
</PageHead>
);
}

View File

@ -1,62 +0,0 @@
import { useCallback } from "react";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
deleteNotification,
listNotifications,
markAllNotificationsRead,
markNotificationRead
} from "@/features/notifications/api";
import { useNotificationAbilities } from "@/features/notifications/permissions.js";
const emptyData = { items: [], unread: 0 };
export function useNotificationsPage() {
const abilities = useNotificationAbilities();
const confirm = useConfirm();
const { showToast } = useToast();
const queryFn = useCallback(() => {
return listNotifications();
}, []);
const { data, error, loading, reload } = useAdminQuery(queryFn, {
errorMessage: "加载通知失败",
initialData: emptyData,
keepPreviousData: true,
queryKey: ["notifications"]
});
const read = async (id) => {
await markNotificationRead(id);
showToast("通知已标记为已读", "success");
reload();
};
const readAll = async () => {
await markAllNotificationsRead();
showToast("通知已全部标记为已读", "success");
reload();
};
const remove = async (item) => {
const ok = await confirm({ confirmText: "删除", message: item.title, title: "删除通知", tone: "danger" });
if (!ok) {
return;
}
await deleteNotification(item.id);
showToast("通知已删除", "success");
reload();
};
return {
abilities,
data: data || emptyData,
error,
loading,
read,
readAll,
reload,
remove
};
}

View File

@ -1,25 +0,0 @@
.notification-list {
display: grid;
}
.notification-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-3);
min-height: 86px;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--row-border);
}
.notification-title {
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 700;
}
.notification-content {
margin: var(--space-2) 0;
color: var(--text-secondary);
font-size: var(--admin-font-size);
}

View File

@ -1,17 +0,0 @@
import { DataState } from "@/shared/ui/DataState.jsx";
import { NotificationList } from "@/features/notifications/components/NotificationList.jsx";
import { NotificationToolbar } from "@/features/notifications/components/NotificationToolbar.jsx";
import { useNotificationsPage } from "@/features/notifications/hooks/useNotificationsPage.js";
export function NotificationsPage() {
const page = useNotificationsPage();
return (
<>
<NotificationToolbar canReadAll={page.abilities.canReadAll} onReadAll={page.readAll} unread={page.data.unread} />
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<NotificationList abilities={page.abilities} items={page.data.items || []} onDelete={page.remove} onRead={page.read} />
</DataState>
</>
);
}

View File

@ -1,12 +0,0 @@
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function useNotificationAbilities() {
const { can } = useAuth();
return {
canDelete: can(PERMISSIONS.notificationDelete),
canRead: can(PERMISSIONS.notificationRead),
canReadAll: can(PERMISSIONS.notificationReadAll)
};
}

View File

@ -1,12 +0,0 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const notificationsRoutes = [
{
label: "通知中心",
loader: () => import("./pages/NotificationsPage.jsx").then((module) => module.NotificationsPage),
menuCode: MENU_CODES.notifications,
pageKey: "notifications",
path: "/notifications",
permission: PERMISSIONS.notificationView
}
];

View File

@ -0,0 +1,11 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiPage, CoinLedgerEntryDto, PageQuery } from "@/shared/api/types";
export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedgerEntryDto>> {
const endpoint = API_ENDPOINTS.listCoinLedger;
return apiRequest<ApiPage<CoinLedgerEntryDto>>(apiEndpointPath(API_OPERATIONS.listCoinLedger), {
method: endpoint.method,
query,
});
}

View File

@ -0,0 +1,170 @@
.amount {
display: inline-flex;
align-items: center;
color: var(--success);
font-size: calc(var(--admin-font-size) + 2px);
font-weight: 760;
line-height: 1;
white-space: nowrap;
}
.amountIncome {
color: var(--success);
}
.amountExpense {
color: var(--danger);
}
.clickableRow:hover {
background: var(--primary-hover);
}
.clickableRow:focus-visible {
outline: none;
background: var(--primary-hover);
box-shadow: inset 0 0 0 2px var(--primary-border-strong);
}
.identity {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.avatar,
.detailAvatar {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--bg-card-strong);
color: var(--text-secondary);
}
.avatar {
width: 36px;
height: 36px;
}
.avatar img,
.detailAvatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.identityText,
.detailHeroText {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.primaryText {
overflow: hidden;
color: var(--text-primary);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta,
.detailMeta {
min-width: 0;
overflow-wrap: anywhere;
color: var(--text-tertiary);
font-size: 12px;
}
.detailHero {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-4);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--border);
}
.detailAvatar {
width: 64px;
height: 64px;
}
.detailTitleRow {
display: flex;
min-width: 0;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.detailTitleRow h3 {
min-width: 0;
overflow: hidden;
margin: 0;
color: var(--text-primary);
font-size: 18px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.detailSection {
display: grid;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card-strong);
}
.detailSectionTitle {
margin: 0;
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 800;
}
.detailGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.detailItem {
display: grid;
min-width: 0;
gap: 4px;
}
.detailLabel {
color: var(--text-tertiary);
font-size: 12px;
}
.detailValue {
min-width: 0;
overflow-wrap: anywhere;
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 650;
}
.detailCode {
max-height: 180px;
margin: 0;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 640px) {
.detailGrid {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,509 @@
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useMemo, useState } from "react";
import { listCoinLedger } from "@/features/operations/api";
import {
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import styles from "@/features/operations/operations.module.css";
const pageSize = 20;
const columns = [
{
key: "user",
label: "用户",
width: "minmax(260px, 1.35fr)",
render: (entry) => <UserCell entry={entry} />,
},
{
key: "amount",
label: "数量",
width: "minmax(120px, 0.65fr)",
render: (entry) => <AmountValue entry={entry} />,
},
{
key: "bizType",
label: "类型",
width: "minmax(150px, 0.8fr)",
render: (entry) => bizTypeLabel(entry.bizType),
},
{
key: "balance",
label: "余额",
width: "minmax(120px, 0.65fr)",
render: (entry) => formatNumber(entry.availableAfter),
},
{
key: "createdAtMs",
label: "时间",
width: "minmax(170px, 0.85fr)",
render: (entry) => formatMillis(entry.createdAtMs),
},
];
const hiddenMetadataKeys = new Set(["presentation_json", "resource_snapshot_json"]);
const metadataLabels = {
amount: "数量",
app_code: "App",
applied_at_ms: "入账时间",
asset_type: "资产类型",
available_delta: "可用变化",
balance_after: "余额",
billing_receipt_id: "账单 ID",
charge_amount: "扣费数量",
charge_asset_type: "扣费资产",
coin_amount: "金币数量",
coin_price: "单价",
coin_spent: "消耗金币",
counts_as_seller_recharge: "计入币商充值",
cycle_key: "周期",
evidence_ref: "凭证",
game_id: "游戏 ID",
gift_count: "礼物数量",
gift_id: "礼物 ID",
gift_name: "礼物名称",
gift_point_added: "礼物积分",
heat_value: "热度",
operator_user_id: "操作人",
op_type: "操作类型",
paid_amount_micro: "支付金额",
paid_currency_code: "支付币种",
payment_ref: "支付凭证",
platform_code: "游戏平台",
price_version: "价格版本",
provider_order_id: "游戏订单",
provider_round_id: "游戏局号",
reason: "原因",
recharge_currency_code: "充值币种",
recharge_policy_coin_amount: "策略金币",
recharge_policy_id: "充值策略 ID",
recharge_policy_usd_minor_amount: "策略 USD 金额",
recharge_policy_version: "充值策略版本",
recharge_usd_minor: "充值 USD 金额",
room_id: "房间 ID",
seller_asset_type: "币商资产",
seller_balance_after: "币商余额",
seller_region_id: "币商区域",
seller_user_id: "币商用户",
sender_user_id: "赠送人",
sort_order: "排序",
stock_type: "进货类型",
target_asset_type: "目标资产",
target_balance_after: "接收方余额",
target_region_id: "接收方区域",
target_user_id: "接收用户",
task_id: "任务 ID",
task_type: "任务类型",
user_id: "用户 ID",
};
export function CoinLedgerPage() {
const [page, setPage] = useState(1);
const [selectedEntry, setSelectedEntry] = useState(null);
const [userKeyword, setUserKeyword] = useState("");
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
const filters = useMemo(
() => ({
end_at_ms: timeRange.endMs || "",
start_at_ms: timeRange.startMs || "",
user_keyword: userKeyword,
}),
[timeRange.endMs, timeRange.startMs, userKeyword],
);
const query = usePaginatedQuery({
errorMessage: "获取金币流水失败",
fetcher: listCoinLedger,
filters,
page,
pageSize,
queryKey: ["coin-ledger", filters, page],
});
const data = query.data || { items: [], total: 0, page, pageSize };
const items = data.items || [];
const total = data.total || 0;
const changeFilter = (setter) => (value) => {
setter(value);
setPage(1);
};
const changeTimeRange = (nextRange) => {
setTimeRange(nextRange);
setPage(1);
};
const resetFilters = () => {
setUserKeyword("");
setTimeRange({ endMs: "", startMs: "" });
setPage(1);
};
const closeDetail = () => {
setSelectedEntry(null);
};
const tableColumns = columns.map((column) =>
column.key === "user"
? {
...column,
filter: createTextColumnFilter({
placeholder: "长 ID / 短 ID / 昵称",
value: userKeyword,
onChange: changeFilter(setUserKeyword),
}),
}
: column,
);
return (
<AdminListPage>
<AdminListToolbar
filters={
<>
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
<AdminFilterResetButton
disabled={!userKeyword && !timeRange.startMs && !timeRange.endMs}
onClick={resetFilters}
/>
</>
}
/>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
getRowProps={(entry) => coinLedgerRowProps(entry, setSelectedEntry)}
items={items}
minWidth="900px"
pagination={
total > 0
? {
page,
pageSize: data.pageSize || pageSize,
total,
onPageChange: setPage,
}
: undefined
}
rowKey={(entry) => entry.entryId}
/>
</AdminListBody>
</DataState>
<CoinLedgerDetailDrawer entry={selectedEntry} open={Boolean(selectedEntry)} onClose={closeDetail} />
</AdminListPage>
);
}
function coinLedgerRowProps(entry, onOpen) {
const user = entry.user || {};
const userLabel = user.username || user.displayUserId || user.userId || entry.userId || "用户";
return {
"aria-label": `查看 ${userLabel} 的金币流水详情`,
className: styles.clickableRow,
role: "button",
tabIndex: 0,
onClick: () => onOpen(entry),
onKeyDown: (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen(entry);
}
},
};
}
function CoinLedgerDetailDrawer({ entry, onClose, open }) {
const metadata = metadataObject(entry?.metadata);
const contextRows = entry ? businessContextRows(entry, metadata) : [];
const usedMetadataKeys = new Set(contextRows.flatMap((row) => row.metadataKeys || []));
const metadataRows = metadataDetailRows(metadata, usedMetadataKeys);
const user = entry?.user || {};
return (
<SideDrawer open={open} title="流水详情" width="wide" onClose={onClose}>
{entry ? (
<>
<header className={styles.detailHero}>
<span className={styles.detailAvatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="medium" />}
</span>
<div className={styles.detailHeroText}>
<div className={styles.detailTitleRow}>
<h3>{user.username || "-"}</h3>
<AmountValue entry={entry} />
</div>
<span className={styles.detailMeta}>{userIdText(entry)}</span>
</div>
</header>
<DetailSection
items={[
detailRow("交易 ID", entry.transactionId),
detailRow("命令 ID", entry.commandId),
detailRow("外部单号", entry.externalRef),
detailRow("流水 ID", entry.entryId),
detailRow("业务类型", bizTypeLabel(entry.bizType)),
detailRow("方向", directionLabel(entry.direction)),
detailRow("发生时间", formatMillis(entry.createdAtMs)),
]}
title="交易信息"
/>
<DetailSection
items={[
detailRow("流水数量", formatSignedAmount(entry)),
detailRow("可用变化", formatSignedDelta(entry.availableDelta)),
detailRow("可用余额", formatNumber(entry.availableAfter)),
]}
title="金额变化"
/>
<DetailSection items={contextRows} title="业务上下文" />
<DetailSection items={metadataRows} title="扩展明细" />
</>
) : null}
</SideDrawer>
);
}
function UserCell({ entry }) {
const user = entry.user || {};
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<span className={styles.primaryText}>{user.username || "-"}</span>
<span className={styles.meta}>{userIdText(entry)}</span>
</div>
</div>
);
}
function AmountValue({ entry }) {
const expense = entry.direction === "expense";
return (
<span className={`${styles.amount} ${expense ? styles.amountExpense : styles.amountIncome}`}>
{formatSignedAmount(entry)}
</span>
);
}
function DetailSection({ items, title }) {
const visibleItems = items.filter((item) => hasDetailValue(item.value));
if (!visibleItems.length) {
return null;
}
return (
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>{title}</h3>
<div className={styles.detailGrid}>
{visibleItems.map((item) => (
<DetailItem key={item.label} label={item.label} value={item.value} />
))}
</div>
</section>
);
}
function DetailItem({ label, value }) {
return (
<div className={styles.detailItem}>
<span className={styles.detailLabel}>{label}</span>
<div className={styles.detailValue}>{displayDetailValue(value)}</div>
</div>
);
}
function businessContextRows(entry, metadata) {
if (entry.bizType === "gift_debit") {
return compactRows([
metadataRow("礼物名称", metadata, ["gift_name", "giftName"]),
metadataRow("礼物 ID", metadata, ["gift_id", "giftId"]),
metadataRow("礼物数量", metadata, ["gift_count", "giftCount"]),
metadataRow("赠送给", metadata, ["target_user_id", "targetUserId"], entry.counterpartyUserId),
metadataRow("赠送人", metadata, ["sender_user_id", "senderUserId"], entry.userId),
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
metadataRow("消耗金币", metadata, ["coin_spent", "coinSpent", "charge_amount", "chargeAmount"]),
metadataRow("礼物积分", metadata, ["gift_point_added", "giftPointAdded"]),
metadataRow("热度", metadata, ["heat_value", "heatValue"]),
metadataRow("账单 ID", metadata, ["billing_receipt_id", "billingReceiptId"]),
metadataRow("价格版本", metadata, ["price_version", "priceVersion"]),
]);
}
if (String(entry.bizType || "").startsWith("game_")) {
return compactRows([
metadataRow("游戏 ID", metadata, ["game_id", "gameId"]),
metadataRow("游戏平台", metadata, ["platform_code", "platformCode"]),
metadataRow("游戏订单", metadata, ["provider_order_id", "providerOrderId"], entry.externalRef),
metadataRow("游戏局号", metadata, ["provider_round_id", "providerRoundId"]),
metadataRow("游戏操作", metadata, ["op_type", "opType"]),
metadataRow("游戏房间", metadata, ["room_id", "roomId"], entry.roomId),
metadataRow("游戏金币", metadata, ["coin_amount", "coinAmount"]),
]);
}
if (entry.bizType === "coin_seller_transfer") {
return compactRows([
metadataRow("币商用户", metadata, ["seller_user_id", "sellerUserId"]),
metadataRow("接收用户", metadata, ["target_user_id", "targetUserId"], entry.counterpartyUserId),
metadataRow("转账金额", metadata, ["amount"]),
metadataRow("原因", metadata, ["reason"]),
metadataRow("币商余额", metadata, ["seller_balance_after", "sellerBalanceAfter"]),
metadataRow("接收方余额", metadata, ["target_balance_after", "targetBalanceAfter"]),
metadataRow("充值策略 ID", metadata, ["recharge_policy_id", "rechargePolicyId"]),
metadataRow("充值币种", metadata, ["recharge_currency_code", "rechargeCurrencyCode"]),
]);
}
return compactRows([
metadataRow("关联用户", metadata, ["target_user_id", "targetUserId", "seller_user_id", "sellerUserId"], entry.counterpartyUserId),
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
metadataRow("任务 ID", metadata, ["task_id", "taskId"]),
metadataRow("任务类型", metadata, ["task_type", "taskType"]),
metadataRow("周期", metadata, ["cycle_key", "cycleKey"]),
metadataRow("原因", metadata, ["reason"]),
metadataRow("操作人", metadata, ["operator_user_id", "operatorUserId"]),
metadataRow("凭证", metadata, ["evidence_ref", "evidenceRef"]),
]);
}
function metadataDetailRows(metadata, usedMetadataKeys) {
return Object.entries(metadata)
.filter(([key, value]) => !usedMetadataKeys.has(key) && !hiddenMetadataKeys.has(key) && hasDetailValue(value))
.map(([key, value]) => detailRow(metadataLabel(key), normalizeMetadataValue(value), [key]));
}
function metadataRow(label, metadata, keys, fallback) {
return detailRow(label, firstValue(valueFromMetadata(metadata, keys), fallback), keys);
}
function detailRow(label, value, metadataKeys = []) {
return { label, metadataKeys, value };
}
function compactRows(rows) {
return rows.filter((row) => hasDetailValue(row.value));
}
function valueFromMetadata(metadata, keys) {
for (const key of keys) {
if (hasDetailValue(metadata[key])) {
return metadata[key];
}
}
return undefined;
}
function firstValue(...values) {
return values.find((value) => hasDetailValue(value));
}
function metadataObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value;
}
function normalizeMetadataValue(value) {
if (typeof value !== "string") {
return value;
}
const trimmed = value.trim();
if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) {
return value;
}
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function displayDetailValue(value) {
if (!hasDetailValue(value)) {
return "-";
}
if (typeof value === "object") {
return <pre className={styles.detailCode}>{JSON.stringify(value, null, 2)}</pre>;
}
return String(value);
}
function userIdText(entry) {
const user = entry.user || {};
const longId = user.userId || entry.userId || "";
const shortId = user.displayUserId || "";
if (longId && shortId && longId !== shortId) {
return `${longId}(${shortId})`;
}
return longId || shortId || "-";
}
function hasDetailValue(value) {
return value === 0 || value === false || Boolean(value);
}
function formatSignedAmount(entry) {
const sign = entry.direction === "expense" ? "-" : "+";
const amount = hasDetailValue(entry.amount) ? entry.amount : Math.abs(Number(entry.availableDelta || 0));
return `${sign}${formatNumber(amount)}`;
}
function formatSignedDelta(value) {
if (!hasDetailValue(value)) {
return "-";
}
const numberValue = Number(value);
if (Number.isNaN(numberValue)) {
return String(value);
}
return `${numberValue > 0 ? "+" : ""}${formatNumber(numberValue)}`;
}
function formatNumber(value) {
if (value === 0 || value) {
return Number(value).toLocaleString("zh-CN");
}
return "-";
}
function directionLabel(value) {
const labels = {
expense: "支出",
income: "收入",
};
return labels[value] || value || "-";
}
function metadataLabel(key) {
return metadataLabels[key] || key;
}
function bizTypeLabel(value) {
const labels = {
coin_seller_transfer: "币商转账",
game_credit: "游戏入账",
game_debit: "游戏扣费",
game_refund: "游戏退款",
game_reverse: "游戏冲正",
gift_debit: "礼物扣费",
manual_credit: "人工入账",
resource_grant: "资源发放",
task_reward: "任务奖励",
vip_purchase: "VIP 购买",
};
return labels[value] || value || "-";
}

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const operationsRoutes = [
{
label: "金币流水",
loader: () => import("./pages/CoinLedgerPage.jsx").then((module) => module.CoinLedgerPage),
menuCode: MENU_CODES.operationCoinLedger,
pageKey: "operation-coin-ledger",
path: "/operations/coin-ledger",
permission: PERMISSIONS.coinLedgerView,
},
];

View File

@ -1,11 +1,58 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiPage, PageQuery, RechargeBillDto } from "@/shared/api/types";
import type {
ApiPage,
EntityId,
PageQuery,
RechargeBillDto,
RechargeProductDto,
RechargeProductPayload,
} from "@/shared/api/types";
export function listRechargeBills(query: PageQuery = {}): Promise<ApiPage<RechargeBillDto>> {
const endpoint = API_ENDPOINTS.listRechargeBills;
return apiRequest<ApiPage<RechargeBillDto>>(apiEndpointPath(API_OPERATIONS.listRechargeBills), {
method: endpoint.method,
query
});
const endpoint = API_ENDPOINTS.listRechargeBills;
return apiRequest<ApiPage<RechargeBillDto>>(apiEndpointPath(API_OPERATIONS.listRechargeBills), {
method: endpoint.method,
query,
});
}
export function listRechargeProducts(query: PageQuery = {}): Promise<ApiPage<RechargeProductDto>> {
const endpoint = API_ENDPOINTS.listRechargeProducts;
return apiRequest<ApiPage<RechargeProductDto>>(apiEndpointPath(API_OPERATIONS.listRechargeProducts), {
method: endpoint.method,
query,
});
}
export function createRechargeProduct(payload: RechargeProductPayload): Promise<RechargeProductDto> {
const endpoint = API_ENDPOINTS.createRechargeProduct;
return apiRequest<RechargeProductDto, RechargeProductPayload>(
apiEndpointPath(API_OPERATIONS.createRechargeProduct),
{
body: payload,
method: endpoint.method,
},
);
}
export function updateRechargeProduct(
productId: EntityId,
payload: RechargeProductPayload,
): Promise<RechargeProductDto> {
const endpoint = API_ENDPOINTS.updateRechargeProduct;
return apiRequest<RechargeProductDto, RechargeProductPayload>(
apiEndpointPath(API_OPERATIONS.updateRechargeProduct, { product_id: productId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function deleteRechargeProduct(productId: EntityId): Promise<unknown> {
const endpoint = API_ENDPOINTS.deleteRechargeProduct;
return apiRequest(apiEndpointPath(API_OPERATIONS.deleteRechargeProduct, { product_id: productId }), {
method: endpoint.method,
});
}

View File

@ -0,0 +1,197 @@
import { useEffect, useMemo, useState } from "react";
import {
createRechargeProduct,
deleteRechargeProduct,
listRechargeProducts,
updateRechargeProduct,
} from "@/features/payment/api";
import { usePaymentAbilities } from "@/features/payment/permissions.js";
import { rechargeProductSchema } from "@/features/payment/schema";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const pageSize = 20;
const emptyForm = () => ({
amountUsdt: "",
coinAmount: "",
description: "",
enabled: true,
platform: "android",
productName: "",
regionIds: [],
});
export function useRechargeProductsPage() {
const abilities = usePaymentAbilities();
const confirm = useConfirm();
const { showToast } = useToast();
const { loadingRegions, regionOptions } = useRegionOptions();
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("");
const [platform, setPlatform] = useState("");
const [regionId, setRegionId] = useState("");
const [page, setPage] = useState(1);
const [activeAction, setActiveAction] = useState("");
const [editingItem, setEditingItem] = useState(null);
const [form, setFormState] = useState(emptyForm);
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(
() => ({
keyword,
platform,
regionId,
status,
}),
[keyword, platform, regionId, status],
);
const result = usePaginatedQuery({
errorMessage: "加载内购配置失败",
fetcher: listRechargeProducts,
filters,
page,
pageSize,
queryKey: ["payment", "recharge-products", filters, page],
});
useEffect(() => {
setPage(1);
}, [filters]);
const setForm = (patch) => {
setFormState((current) => ({ ...current, ...patch }));
};
const openCreate = () => {
setEditingItem(null);
setFormState(emptyForm());
setActiveAction("create");
};
const openEdit = (item) => {
setEditingItem(item);
setFormState(formFromProduct(item));
setActiveAction("edit");
};
const closeDialog = () => {
setActiveAction("");
setEditingItem(null);
setFormState(emptyForm());
};
const resetFilters = () => {
setKeyword("");
setStatus("");
setPlatform("");
setRegionId("");
};
const submitProduct = async (event) => {
event.preventDefault();
let payload;
try {
payload = parseForm(rechargeProductSchema, normalizeForm(form));
} catch (err) {
showToast(err.message || "表单校验失败", "error");
return;
}
const action = editingItem ? "edit" : "create";
setLoadingAction(action);
try {
if (editingItem) {
await updateRechargeProduct(editingItem.productId, payload);
showToast("内购配置已更新", "success");
} else {
await createRechargeProduct(payload);
showToast("内购配置已新增", "success");
}
closeDialog();
await result.reload();
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
const removeProduct = async (item) => {
const ok = await confirm({
confirmText: "删除",
message: item.productName || item.productCode || `内购配置 ${item.productId}`,
title: "删除内购配置",
tone: "danger",
});
if (!ok) {
return;
}
setLoadingAction(`delete:${item.productId}`);
try {
await deleteRechargeProduct(item.productId);
await result.reload();
showToast("内购配置已删除", "success");
} catch (err) {
showToast(err.message || "删除失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
closeDialog,
data: result.data,
editingItem,
error: result.error,
form,
keyword,
loading: result.loading,
loadingAction,
loadingRegions,
openCreate,
openEdit,
page,
pageSize,
platform,
regionId,
regionOptions,
reload: result.reload,
removeProduct,
resetFilters,
setForm,
setKeyword,
setPage,
setPlatform,
setRegionId,
setStatus,
status,
submitProduct,
};
}
function normalizeForm(form) {
return {
...form,
amountUsdt: form.amountUsdt || "",
coinAmount: form.coinAmount || "",
regionIds: form.regionIds || [],
};
}
function formFromProduct(item) {
return {
amountUsdt: item.amountUsdt || "",
coinAmount: item.coinAmount === 0 || item.coinAmount ? String(item.coinAmount) : "",
description: item.description || "",
enabled: item.enabled ?? item.status === "active",
platform: item.platform || "android",
productName: item.productName || "",
regionIds: (item.regionIds || []).map(String),
};
}

View File

@ -1,14 +1,11 @@
import { useMemo, useState } from "react";
import { listRechargeBills } from "@/features/payment/api";
import {
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
@ -130,14 +127,6 @@ export function PaymentBillListPage() {
setPage(1);
};
const resetFilters = () => {
setKeyword("");
setStatus("");
setRechargeType("");
setUserId("");
setSellerUserId("");
setPage(1);
};
const tableColumns = columns.map((column) => {
if (column.key === "bill") {
return {
@ -196,30 +185,24 @@ export function PaymentBillListPage() {
return (
<AdminListPage>
<AdminListToolbar
filters={
<AdminFilterResetButton
disabled={!keyword && !status && !rechargeType && !userId && !sellerUserId}
onClick={resetFilters}
/>
}
/>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
items={items}
minWidth="1260px"
pagination={
total > 0
? {
page,
pageSize: data.pageSize || pageSize,
total,
onPageChange: setPage,
}
: undefined
}
rowKey={(bill) => bill.transactionId}
/>
{total > 0 ? (
<PaginationBar
page={page}
pageSize={data.pageSize || pageSize}
total={total}
onPageChange={setPage}
/>
) : null}
</AdminListBody>
</DataState>
</AdminListPage>

View File

@ -0,0 +1,340 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useMemo } from "react";
import { useRechargeProductsPage } from "@/features/payment/hooks/useRechargeProductsPage.js";
import { Button } from "@/shared/ui/Button.jsx";
import {
AdminFormAmountField,
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
const statusOptions = [
["", "全部状态"],
["active", "上架"],
["disabled", "下架"],
];
const platformOptions = [
["", "全部平台"],
["android", "Android"],
["ios", "iOS"],
];
export function RechargeProductConfigPage() {
const page = useRechargeProductsPage();
const items = page.data?.items || [];
const regionLabels = useMemo(() => regionLabelMap(page.regionOptions), [page.regionOptions]);
const regionValues = useMemo(() => page.regionOptions.map((option) => option.value), [page.regionOptions]);
const columns = useMemo(() => productColumns(page, regionLabels), [page, regionLabels]);
const saving = page.loadingAction === "create" || page.loadingAction === "edit";
const canSubmit = page.editingItem ? page.abilities.canUpdateProduct : page.abilities.canCreateProduct;
return (
<AdminListPage>
<AdminListToolbar
actions={
page.abilities.canCreateProduct ? (
<Button
startIcon={<AddOutlined fontSize="small" />}
variant="primary"
onClick={page.openCreate}
>
新增内购
</Button>
) : null
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={columns}
items={items}
minWidth="1180px"
pagination={{
page: page.page,
pageSize: page.pageSize,
total: page.data?.total || 0,
onPageChange: page.setPage,
}}
rowKey={(item) => item.productId}
/>
</AdminListBody>
</DataState>
<AdminFormDialog
loading={saving}
open={Boolean(page.activeAction)}
size="compact"
submitDisabled={!canSubmit || saving}
title={page.editingItem ? "编辑内购配置" : "新增内购配置"}
onClose={page.closeDialog}
onSubmit={page.submitProduct}
>
<AdminFormSection title="商品信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={!canSubmit}
label="产品名称"
required
value={page.form.productName}
onChange={(event) => page.setForm({ productName: event.target.value })}
/>
<TextField
disabled={!canSubmit}
label="平台"
required
select
value={page.form.platform}
onChange={(event) => page.setForm({ platform: event.target.value })}
>
<MenuItem value="android">Android</MenuItem>
<MenuItem value="ios">iOS</MenuItem>
</TextField>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="价格配置">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<AdminFormAmountField
disabled={!canSubmit}
inputProps={{ inputMode: "decimal" }}
label="金额USDT"
required
unit="USDT"
value={page.form.amountUsdt}
onChange={(event) => page.setForm({ amountUsdt: event.target.value })}
/>
<TextField
disabled={!canSubmit}
inputProps={{ min: 1, step: 1 }}
label="获得金币数"
required
type="number"
value={page.form.coinAmount}
onChange={(event) => page.setForm({ coinAmount: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="投放设置">
<MultiValueAutocomplete
disabled={!canSubmit}
label="支持区域"
labels={regionLabels}
loading={page.loadingRegions}
options={regionValues}
required
value={page.form.regionIds}
onChange={(regionIds) => page.setForm({ regionIds })}
/>
<AdminFormSwitchField
checked={Boolean(page.form.enabled)}
checkedLabel="上架"
disabled={!canSubmit}
label="是否上架"
uncheckedLabel="下架"
onChange={(checked) => page.setForm({ enabled: checked })}
/>
</AdminFormSection>
<AdminFormSection title="描述">
<TextField
disabled={!canSubmit}
label="描述"
multiline
minRows={2}
value={page.form.description}
onChange={(event) => page.setForm({ description: event.target.value })}
/>
</AdminFormSection>
</AdminFormDialog>
</AdminListPage>
);
}
function productColumns(page, regionLabels) {
const columns = [
{
key: "product",
label: "产品",
render: (item) => <Stack primary={item.productName} secondary={item.productCode || "-"} />,
filter: createTextColumnFilter({
placeholder: "搜索产品、描述",
value: page.keyword,
onChange: page.setKeyword,
}),
width: "minmax(240px, 1.2fr)",
},
{
key: "amount",
label: "金额 / 金币",
render: (item) => (
<Stack
primary={`${item.amountUsdt || formatMicroAmount(item.amountUsdtMicro)} USDT`}
secondary={`${formatNumber(item.coinAmount)} 金币`}
/>
),
width: "minmax(160px, 0.75fr)",
},
{
key: "platform",
label: "平台",
render: (item) => platformLabel(item.platform),
filter: createOptionsColumnFilter({
options: platformOptions,
placeholder: "搜索平台",
value: page.platform,
onChange: page.setPlatform,
}),
width: "minmax(120px, 0.55fr)",
},
{
key: "regionIds",
label: "支持区域",
render: (item) => regionListLabel(regionLabels, item.regionIds),
filter: createRegionColumnFilter({
loading: page.loadingRegions,
options: page.regionOptions,
value: page.regionId,
onChange: page.setRegionId,
}),
width: "minmax(220px, 1fr)",
},
{
key: "status",
label: "状态",
render: (item) => <StatusBadge enabled={item.enabled} />,
filter: createOptionsColumnFilter({
options: statusOptions,
placeholder: "搜索状态",
value: page.status,
onChange: page.setStatus,
}),
width: "minmax(110px, 0.5fr)",
},
{
key: "description",
label: "描述",
render: (item) => item.description || "-",
width: "minmax(180px, 0.9fr)",
},
{
key: "updatedAtMs",
label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(160px, 0.75fr)",
},
];
if (!page.abilities.canUpdateProduct && !page.abilities.canDeleteProduct) {
return columns;
}
return [
...columns,
{
key: "actions",
label: "操作",
render: (item) => <ProductActions item={item} page={page} />,
width: "minmax(96px, 0.45fr)",
},
];
}
function ProductActions({ item, page }) {
const deleting = page.loadingAction === `delete:${item.productId}`;
return (
<AdminRowActions>
{page.abilities.canUpdateProduct ? (
<AdminActionIconButton
disabled={Boolean(page.loadingAction)}
label="编辑"
onClick={() => page.openEdit(item)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canDeleteProduct ? (
<AdminActionIconButton
disabled={deleting || Boolean(page.loadingAction)}
label="删除"
onClick={() => page.removeProduct(item)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
</AdminRowActions>
);
}
function Stack({ primary, secondary }) {
return (
<div className="cell-stack">
<span>{primary || "-"}</span>
<span className="muted">{secondary || "-"}</span>
</div>
);
}
function StatusBadge({ enabled }) {
const tone = enabled ? "succeeded" : "stopped";
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{enabled ? "上架" : "下架"}
</span>
);
}
function platformLabel(value) {
if (value === "android") {
return "Android";
}
if (value === "ios") {
return "iOS";
}
return value || "-";
}
function regionLabelMap(options) {
return options.reduce((labels, option) => {
labels[String(option.value)] = option.label;
return labels;
}, {});
}
function regionListLabel(labels, regionIds = []) {
if (!regionIds.length) {
return "-";
}
return regionIds.map((regionId) => labels[String(regionId)] || `区域 ${regionId}`).join("、");
}
function formatNumber(value) {
const numberValue = Number(value || 0);
return Number.isFinite(numberValue) ? numberValue.toLocaleString() : "-";
}
function formatMicroAmount(value) {
const numberValue = Number(value || 0);
if (!Number.isFinite(numberValue) || numberValue <= 0) {
return "0";
}
return String(numberValue / 1_000_000).replace(/\.?0+$/, "");
}

View File

@ -0,0 +1,14 @@
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function usePaymentAbilities() {
const { can } = useAuth();
return {
canCreateProduct: can(PERMISSIONS.paymentProductCreate),
canDeleteProduct: can(PERMISSIONS.paymentProductDelete),
canUpdateProduct: can(PERMISSIONS.paymentProductUpdate),
canViewBill: can(PERMISSIONS.paymentBillView),
canViewProduct: can(PERMISSIONS.paymentProductView),
};
}

View File

@ -1,12 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const paymentRoutes = [
{
label: "账单列表",
loader: () => import("./pages/PaymentBillListPage.jsx").then((module) => module.PaymentBillListPage),
menuCode: MENU_CODES.paymentBillList,
pageKey: "payment-bill-list",
path: "/payment/bills",
permission: PERMISSIONS.paymentBillView
}
{
label: "账单列表",
loader: () => import("./pages/PaymentBillListPage.jsx").then((module) => module.PaymentBillListPage),
menuCode: MENU_CODES.paymentBillList,
pageKey: "payment-bill-list",
path: "/payment/bills",
permission: PERMISSIONS.paymentBillView,
}
];

View File

@ -0,0 +1,19 @@
import { z } from "zod";
const usdtAmountSchema = z
.string()
.trim()
.regex(/^(0|[1-9]\d*)(\.\d{1,6})?$/, "金额USDT最多支持 6 位小数")
.refine((value) => !/^0(?:\.0{1,6})?$/.test(value), "金额USDT必须大于 0");
export const rechargeProductSchema = z.object({
amountUsdt: usdtAmountSchema,
coinAmount: z.coerce.number().int("获得金币数必须是整数").positive("获得金币数必须大于 0"),
description: z.string().trim().max(512, "描述不能超过 512 个字符").default(""),
enabled: z.boolean(),
platform: z.enum(["android", "ios"]),
productName: z.string().trim().min(1, "请填写产品名称").max(128, "产品名称不能超过 128 个字符"),
regionIds: z.array(z.coerce.number().int().positive()).min(1, "请选择支持区域"),
});
export type RechargeProductForm = z.infer<typeof rechargeProductSchema>;

View File

@ -0,0 +1,133 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiPage, PageQuery } from "@/shared/api/types";
export interface RegistrationRewardConfigDto {
appCode?: string;
enabled: boolean;
rewardType: string;
coinAmount: number;
resourceGroupId: number;
dailyLimit: number;
updatedByAdminId?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface RegistrationRewardConfigPayload {
enabled: boolean;
reward_type: string;
coin_amount: number;
resource_group_id: number;
daily_limit: number;
}
export interface RegistrationRewardClaimUserDto {
userId?: number;
displayUserId?: string;
username?: string;
avatar?: string;
}
export interface RegistrationRewardClaimDto {
claimId: string;
commandId?: string;
userId: number;
user?: RegistrationRewardClaimUserDto;
rewardDay?: string;
rewardType?: string;
coinAmount?: number;
resourceGroupId?: number;
status?: string;
walletCommandId?: string;
walletTransactionId?: string;
resourceGrantId?: string;
failureReason?: string;
claimedAtMs?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
type RawConfig = RegistrationRewardConfigDto & Record<string, unknown>;
type RawClaim = RegistrationRewardClaimDto & Record<string, unknown>;
export function getRegistrationRewardConfig(): Promise<RegistrationRewardConfigDto> {
const endpoint = API_ENDPOINTS.getRegistrationRewardConfig;
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getRegistrationRewardConfig), {
method: endpoint.method,
}).then(normalizeConfig);
}
export function updateRegistrationRewardConfig(
payload: RegistrationRewardConfigPayload,
): Promise<RegistrationRewardConfigDto> {
const endpoint = API_ENDPOINTS.updateRegistrationRewardConfig;
return apiRequest<RawConfig, RegistrationRewardConfigPayload>(
apiEndpointPath(API_OPERATIONS.updateRegistrationRewardConfig),
{
body: payload,
method: endpoint.method,
},
).then(normalizeConfig);
}
export function listRegistrationRewardClaims(query: PageQuery = {}): Promise<ApiPage<RegistrationRewardClaimDto>> {
const endpoint = API_ENDPOINTS.listRegistrationRewardClaims;
return apiRequest<ApiPage<RawClaim>>(apiEndpointPath(API_OPERATIONS.listRegistrationRewardClaims), {
method: endpoint.method,
query,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizeClaim),
}));
}
function normalizeConfig(item: RawConfig): RegistrationRewardConfigDto {
return {
appCode: stringValue(item.appCode ?? item.app_code),
enabled: Boolean(item.enabled),
rewardType: stringValue(item.rewardType ?? item.reward_type) || "coin",
coinAmount: numberValue(item.coinAmount ?? item.coin_amount),
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
dailyLimit: numberValue(item.dailyLimit ?? item.daily_limit),
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizeClaim(item: RawClaim): RegistrationRewardClaimDto {
const rawUser = (item.user || {}) as Record<string, unknown>;
return {
claimId: stringValue(item.claimId ?? item.claim_id),
commandId: stringValue(item.commandId ?? item.command_id),
userId: numberValue(item.userId ?? item.user_id),
user: {
userId: numberValue(rawUser.userId ?? rawUser.user_id),
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
username: stringValue(rawUser.username),
avatar: stringValue(rawUser.avatar),
},
rewardDay: stringValue(item.rewardDay ?? item.reward_day),
rewardType: stringValue(item.rewardType ?? item.reward_type),
coinAmount: numberValue(item.coinAmount ?? item.coin_amount),
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
status: stringValue(item.status),
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
walletTransactionId: stringValue(item.walletTransactionId ?? item.wallet_transaction_id),
resourceGrantId: stringValue(item.resourceGrantId ?? item.resource_grant_id),
failureReason: stringValue(item.failureReason ?? item.failure_reason),
claimedAtMs: numberValue(item.claimedAtMs ?? item.claimed_at_ms),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}
function numberValue(value: unknown) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}

View File

@ -0,0 +1,156 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { listResourceGroups } from "@/features/resources/api";
import {
getRegistrationRewardConfig,
listRegistrationRewardClaims,
updateRegistrationRewardConfig,
} from "@/features/registration-reward/api";
import { useRegistrationRewardAbilities } from "@/features/registration-reward/permissions.js";
const pageSize = 10;
const emptyClaims = { items: [], page: 1, pageSize, total: 0 };
const emptyForm = {
coinAmount: "",
dailyLimit: "0",
enabled: false,
resourceGroupId: "",
rewardType: "coin",
};
export function useRegistrationRewardPage() {
const abilities = useRegistrationRewardAbilities();
const { showToast } = useToast();
const [form, setForm] = useState(emptyForm);
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [resourceGroups, setResourceGroups] = useState([]);
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const filters = useMemo(() => ({ keyword: query, status }), [query, status]);
const {
data: claims = emptyClaims,
error: claimsError,
loading: claimsLoading,
reload: reloadClaims,
} = usePaginatedQuery({
errorMessage: "加载领取记录失败",
fetcher: listRegistrationRewardClaims,
filters,
page,
pageSize,
queryKey: ["registration-reward-claims", filters, page],
});
const reloadConfig = useCallback(async () => {
setConfigLoading(true);
try {
const [config, groups] = await Promise.all([
getRegistrationRewardConfig(),
listResourceGroups({ page: 1, page_size: 100, status: "active" }),
]);
setForm(formFromConfig(config));
setResourceGroups(groups.items || []);
} catch (err) {
showToast(err.message || "加载注册奖励配置失败", "error");
} finally {
setConfigLoading(false);
}
}, [showToast]);
useEffect(() => {
void reloadConfig();
}, [reloadConfig]);
const changeQuery = (value) => {
setQuery(value);
setPage(1);
};
const changeStatus = (value) => {
setStatus(value);
setPage(1);
};
const resetFilters = () => {
setQuery("");
setStatus("");
setPage(1);
};
const submitConfig = async (event) => {
event.preventDefault();
if (!abilities.canUpdate) {
return;
}
setConfigSaving(true);
try {
const saved = await updateRegistrationRewardConfig(payloadFromForm(form));
setForm(formFromConfig(saved));
showToast("注册奖励配置已保存", "success");
await reloadClaims();
} catch (err) {
showToast(err.message || "保存注册奖励配置失败", "error");
} finally {
setConfigSaving(false);
}
};
return {
abilities,
changeQuery,
changeStatus,
claims,
claimsError,
claimsLoading,
configLoading,
configSaving,
form,
page,
query,
reloadClaims,
reloadConfig,
resetFilters,
resourceGroups,
setForm,
setPage,
status,
submitConfig,
};
}
function formFromConfig(config) {
return {
coinAmount: config.coinAmount ? String(config.coinAmount) : "",
dailyLimit: String(config.dailyLimit || 0),
enabled: Boolean(config.enabled),
resourceGroupId: config.resourceGroupId ? String(config.resourceGroupId) : "",
rewardType: config.rewardType || "coin",
};
}
function payloadFromForm(form) {
const rewardType = form.rewardType === "resource_group" ? "resource_group" : "coin";
const coinAmount = Number(form.coinAmount || 0);
const resourceGroupId = Number(form.resourceGroupId || 0);
const dailyLimit = Number(form.dailyLimit || 0);
if (dailyLimit < 0) {
throw new Error("每日限制不能小于 0");
}
if (rewardType === "coin" && coinAmount <= 0) {
throw new Error("金币奖励必须大于 0");
}
if (rewardType === "resource_group" && resourceGroupId <= 0) {
throw new Error("请选择资源组");
}
return {
coin_amount: rewardType === "coin" ? coinAmount : 0,
daily_limit: dailyLimit,
enabled: Boolean(form.enabled),
resource_group_id: rewardType === "resource_group" ? resourceGroupId : 0,
reward_type: rewardType,
};
}

View File

@ -0,0 +1,278 @@
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Avatar from "@mui/material/Avatar";
import MenuItem from "@mui/material/MenuItem";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import { useRegistrationRewardPage } from "@/features/registration-reward/hooks/useRegistrationRewardPage.js";
import styles from "@/features/registration-reward/registration-reward.module.css";
const rewardTypeOptions = [
["coin", "金币"],
["resource_group", "资源组"],
];
const claimStatusOptions = [
["pending", "发放中"],
["granted", "已发放"],
["failed", "发放失败"],
];
const columnsBase = [
{
key: "user",
label: "用户信息",
width: "minmax(260px, 1.2fr)",
render: (claim) => <ClaimUser claim={claim} />,
},
{
key: "reward",
label: "奖励",
width: "minmax(180px, 0.85fr)",
render: (claim) => <RewardSummary claim={claim} />,
},
{
key: "status",
label: "状态",
width: "minmax(120px, 0.55fr)",
render: (claim) => claimStatusLabel(claim.status),
},
{
key: "claimedAt",
label: "领取时间",
width: "minmax(180px, 0.85fr)",
render: (claim) => formatMillis(claim.claimedAtMs || claim.createdAtMs),
},
{
key: "claimId",
label: "领取记录",
width: "minmax(240px, 1fr)",
render: (claim) => (
<div className={styles.stack}>
<span>{claim.claimId}</span>
<span className={styles.meta}>{claim.rewardDay || "-"}</span>
</div>
),
},
{
key: "failure",
label: "失败原因",
width: "minmax(180px, 0.85fr)",
render: (claim) => claim.failureReason || "-",
},
];
export function RegistrationRewardPage() {
const page = useRegistrationRewardPage();
const total = page.claims.total || 0;
const columns = columnsBase.map((column) => {
if (column.key === "user") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索用户 ID、短号、名称",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: [["", "全部状态"], ...claimStatusOptions],
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
};
}
return column;
});
return (
<AdminListPage>
<form className={styles.configPanel} onSubmit={page.submitConfig}>
<div className={styles.configHeader}>
<div className={styles.stack}>
<span className={styles.title}>注册奖励配置</span>
<span className={styles.meta}>注册成功后由服务端自动发放</span>
</div>
<div className={styles.configActions}>
<AdminActionIconButton
disabled={page.configLoading}
label="刷新配置"
type="button"
onClick={page.reloadConfig}
>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
<Button
disabled={!page.abilities.canUpdate || page.configLoading || page.configSaving}
startIcon={<SaveOutlined fontSize="small" />}
type="submit"
variant="primary"
>
保存
</Button>
</div>
</div>
<div className={styles.configSections}>
<section className={styles.configSection}>
<div className={styles.configSectionTitle}>发放状态</div>
<div className={styles.configGrid}>
<AdminSwitch
checked={page.form.enabled}
checkedLabel="开启"
disabled={!page.abilities.canUpdate || page.configLoading}
label="注册奖励状态"
uncheckedLabel="关闭"
onChange={(event) =>
page.setForm((current) => ({ ...current, enabled: event.target.checked }))
}
/>
<TextField
disabled={!page.abilities.canUpdate || page.configLoading}
inputProps={{ min: 0 }}
label="每日限制份数"
type="number"
value={page.form.dailyLimit}
onChange={(event) =>
page.setForm((current) => ({ ...current, dailyLimit: event.target.value }))
}
/>
</div>
</section>
<section className={styles.configSection}>
<div className={styles.configSectionTitle}>奖励内容</div>
<div className={styles.configGrid}>
<TextField
select
disabled={!page.abilities.canUpdate || page.configLoading}
label="奖励类型"
value={page.form.rewardType}
onChange={(event) =>
page.setForm((current) => ({ ...current, rewardType: event.target.value }))
}
>
{rewardTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
{page.form.rewardType === "resource_group" ? (
<TextField
select
disabled={!page.abilities.canUpdate || page.configLoading}
label="资源组"
value={page.form.resourceGroupId}
onChange={(event) =>
page.setForm((current) => ({
...current,
resourceGroupId: event.target.value,
}))
}
>
<MenuItem value="">请选择资源组</MenuItem>
{page.resourceGroups.map((group) => (
<MenuItem key={group.groupId} value={String(group.groupId)}>
{group.name || group.groupCode || group.groupId}
</MenuItem>
))}
</TextField>
) : (
<TextField
disabled={!page.abilities.canUpdate || page.configLoading}
inputProps={{ min: 1 }}
label="金币数量"
type="number"
value={page.form.coinAmount}
onChange={(event) =>
page.setForm((current) => ({ ...current, coinAmount: event.target.value }))
}
/>
)}
</div>
</section>
</div>
</form>
<AdminListToolbar
actions={
<AdminActionIconButton label="刷新记录" type="button" onClick={page.reloadClaims}>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
}
/>
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
<AdminListBody>
<DataTable
columns={columns}
items={page.claims.items || []}
minWidth="1240px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.claims.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(claim) => claim.claimId}
/>
</AdminListBody>
</DataState>
</AdminListPage>
);
}
function ClaimUser({ claim }) {
const user = claim.user || {};
return (
<div className={styles.identity}>
<Avatar alt={user.username || String(claim.userId)} src={user.avatar || ""} sx={{ width: 36, height: 36 }} />
<div className={styles.stack}>
<span className={styles.name}>{user.username || `用户 ${claim.userId}`}</span>
<span className={styles.meta}>{user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${claim.userId}`}</span>
</div>
</div>
);
}
function RewardSummary({ claim }) {
if (claim.rewardType === "resource_group") {
return (
<div className={styles.stack}>
<span>资源组</span>
<span className={styles.meta}>#{claim.resourceGroupId || "-"}</span>
</div>
);
}
return (
<div className={styles.stack}>
<span>{formatNumber(claim.coinAmount)} 金币</span>
<span className={styles.meta}>注册奖励</span>
</div>
);
}
function claimStatusLabel(status) {
return claimStatusOptions.find(([value]) => value === status)?.[1] || status || "-";
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}

View File

@ -0,0 +1,11 @@
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function useRegistrationRewardAbilities() {
const { can } = useAuth();
return {
canUpdate: can(PERMISSIONS.registrationRewardUpdate),
canView: can(PERMISSIONS.registrationRewardView),
};
}

View File

@ -0,0 +1,94 @@
.configPanel {
display: grid;
gap: var(--space-4);
padding: var(--space-5);
border-bottom: 1px solid var(--border);
}
.configHeader {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.configActions {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.configSections {
display: grid;
gap: var(--space-4);
}
.configSection {
display: grid;
gap: var(--space-3);
}
.configSection + .configSection {
padding-top: var(--space-4);
border-top: 1px solid var(--border-soft);
}
.configSectionTitle {
color: var(--text-secondary);
font-size: 13px;
font-weight: 700;
line-height: 18px;
}
.configGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-3);
}
.switchField {
min-height: var(--control-height);
margin: 0;
}
.identity {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.stack {
display: grid;
min-width: 0;
gap: 2px;
}
.title,
.name {
overflow: hidden;
color: var(--text-primary);
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
overflow: hidden;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 900px) {
.configHeader {
align-items: flex-start;
flex-direction: column;
}
.configGrid {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const registrationRewardRoutes = [
{
label: "注册奖励",
loader: () => import("./pages/RegistrationRewardPage.jsx").then((module) => module.RegistrationRewardPage),
menuCode: MENU_CODES.registrationReward,
pageKey: "registration-reward",
path: "/activities/registration-reward",
permission: PERMISSIONS.registrationRewardView,
},
];

View File

@ -115,6 +115,15 @@ export interface ResourceGrantItemDto {
walletTransactionId?: string;
}
export interface ResourceGrantOperatorDto {
avatar?: string;
displayUserId?: string;
name?: string;
source?: string;
userId?: number | string;
username?: string;
}
export interface ResourceGrantDto {
appCode?: string;
commandId?: string;
@ -123,6 +132,7 @@ export interface ResourceGrantDto {
grantSource?: string;
grantSubjectId?: string;
grantSubjectType?: string;
operator?: ResourceGrantOperatorDto;
items?: ResourceGrantItemDto[];
operatorUserId?: number;
reason?: string;

View File

@ -26,6 +26,7 @@ export const resourceGrantSubjectLabels = {
export const resourceTypeFilters = [
["", "全部类型"],
["avatar_frame", "头像框"],
["profile_card", "资料卡"],
["coin", "金币"],
["vehicle", "座驾"],
["chat_bubble", "气泡"],

View File

@ -85,6 +85,28 @@ const emptyGiftForm = (gift = {}) => ({
resourceId: gift.resourceId ? String(gift.resourceId) : "",
sortOrder: gift.sortOrder === 0 || gift.sortOrder ? String(gift.sortOrder) : "0",
});
export function applyGiftResourceSelection(form, resource) {
const resourceId = resource?.resourceId ? String(resource.resourceId) : "";
return {
...form,
giftId: resourceId,
name: resource?.name || resource?.resourceCode || resourceId,
resourceId,
};
}
export function applyGiftPriceDefaults(form, coinPrice) {
const priceValue = String(coinPrice ?? "");
const priceNumber = Number(priceValue);
return {
...form,
coinPrice: priceValue,
giftPointAmount: priceValue,
heatValue: priceValue.trim() && Number.isFinite(priceNumber) ? String(Math.ceil(priceNumber / 100)) : "",
};
}
const emptyGrantForm = () => ({
durationDays: "0",
groupId: "",
@ -502,6 +524,20 @@ export function useGiftListPage() {
setSelectedGift(null);
};
const selectGiftResource = (resourceId) => {
const selectedResource = resourceOptions.find((resource) => String(resource.resourceId) === String(resourceId));
setForm((currentForm) => {
if (activeAction !== "create") {
return { ...currentForm, resourceId };
}
return applyGiftResourceSelection(currentForm, selectedResource || { resourceId });
});
};
const changeGiftPrice = (coinPrice) => {
setForm((currentForm) => applyGiftPriceDefaults(currentForm, coinPrice));
};
const submitGift = async (event) => {
event.preventDefault();
const editing = activeAction === "edit";
@ -560,6 +596,7 @@ export function useGiftListPage() {
...sharedPageState({ page, query, result, setPage, setQuery }),
abilities,
activeAction,
changeGiftPrice,
changeRegionId: resetSetter(setRegionId, setPage),
changeStatus: resetSetter(setStatus, setPage),
closeAction,
@ -574,6 +611,7 @@ export function useGiftListPage() {
resourceOptionsLoading,
selectedGift,
resetFilters,
selectGiftResource,
setForm,
status,
submitGift,

View File

@ -0,0 +1,44 @@
import { expect, test } from "vitest";
import { applyGiftPriceDefaults, applyGiftResourceSelection } from "./useResourcePages.js";
test("fills gift name and id from selected gift resource", () => {
const form = {
coinPrice: "10",
giftId: "",
name: "",
resourceId: "",
};
const nextForm = applyGiftResourceSelection(form, {
name: "Rose",
resourceCode: "gift_rose",
resourceId: 11,
});
expect(nextForm).toMatchObject({
coinPrice: "10",
giftId: "11",
name: "Rose",
resourceId: "11",
});
});
test("fills gift points and heat from gift price", () => {
const form = {
coinPrice: "",
giftPointAmount: "0",
heatValue: "0",
name: "Rose",
};
expect(applyGiftPriceDefaults(form, "100")).toMatchObject({
coinPrice: "100",
giftPointAmount: "100",
heatValue: "1",
});
expect(applyGiftPriceDefaults(form, "101")).toMatchObject({
coinPrice: "101",
giftPointAmount: "101",
heatValue: "2",
});
});

View File

@ -6,26 +6,28 @@ import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment";
import FormControlLabel from "@mui/material/FormControlLabel";
import MenuItem from "@mui/material/MenuItem";
import Popover from "@mui/material/Popover";
import Switch from "@mui/material/Switch";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import { useMemo, useState } from "react";
import { AdminFormDialog, AdminFormFieldGrid } from "@/shared/ui/AdminFormDialog.jsx";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminRowActions,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
@ -182,12 +184,6 @@ export function GiftListPage() {
return (
<AdminListPage>
<AdminListToolbar
filters={
<AdminFilterResetButton
disabled={!page.query && !page.status && !page.regionId}
onClick={page.resetFilters}
/>
}
actions={
page.abilities.canCreateGift ? (
<AdminActionIconButton label="添加礼物" primary onClick={page.openCreateGift}>
@ -198,15 +194,22 @@ export function GiftListPage() {
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable columns={tableColumns} items={items} minWidth="1480px" rowKey={(gift) => gift.giftId} />
{total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
<DataTable
columns={tableColumns}
items={items}
minWidth="1480px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(gift) => gift.giftId}
/>
</AdminListBody>
</DataState>
<GiftFormDialog
@ -241,7 +244,7 @@ function GiftRowActions({ gift, page }) {
function GiftStatusSwitch({ gift, page }) {
const checked = gift.status === "active";
return (
<Switch
<AdminSwitch
checked={checked}
disabled={!page.abilities.canStatusGift || page.loadingAction === `gift-status-${gift.giftId}`}
inputProps={{ "aria-label": checked ? "禁用礼物" : "启用礼物" }}
@ -252,6 +255,7 @@ function GiftStatusSwitch({ gift, page }) {
function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page, regionOptions }) {
const submitDisabled = disabled || loading || page.resourceOptionsLoading || page.loadingRegions;
const showGiftIdentityFields = mode === "edit" || Boolean(form.resourceId);
return (
<AdminFormDialog
loading={loading}
@ -262,128 +266,140 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={disabled}
label="礼物名称"
required
value={form.name}
onChange={(event) => page.setForm({ ...form, name: event.target.value })}
/>
<TextField
disabled={disabled || mode === "edit"}
label="礼物 ID"
required
value={form.giftId}
onChange={(event) => page.setForm({ ...form, giftId: event.target.value })}
/>
<TextField
disabled={disabled}
label="礼物类型"
required
select
value={form.giftTypeCode}
onChange={(event) => page.setForm({ ...form, giftTypeCode: event.target.value })}
>
{giftTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled || page.resourceOptionsLoading}
label="礼物资源"
required
select
value={form.resourceId}
onChange={(event) => page.setForm({ ...form, resourceId: event.target.value })}
>
{page.resourceOptions.map((resource) => (
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
{resource.name || resource.resourceCode || resource.resourceId}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="收费类型"
required
select
value={form.chargeAssetType}
onChange={(event) => page.setForm({ ...form, chargeAssetType: event.target.value })}
>
{chargeAssetOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="价格"
required
type="number"
value={form.coinPrice}
onChange={(event) => page.setForm({ ...form, coinPrice: event.target.value })}
/>
<TextField
disabled={disabled}
label="积分"
type="number"
value={form.giftPointAmount}
onChange={(event) => page.setForm({ ...form, giftPointAmount: event.target.value })}
/>
<TextField
disabled={disabled}
label="热度"
type="number"
value={form.heatValue}
onChange={(event) => page.setForm({ ...form, heatValue: event.target.value })}
/>
<TextField
disabled={disabled}
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
/>
<GiftRegionSelect
disabled={disabled || page.loadingRegions}
labels={buildRegionLabelMap(regionOptions)}
options={regionOptions}
value={form.regionIds}
onChange={(regionIds) => page.setForm({ ...form, regionIds })}
/>
<DateTimeField
disabled={disabled}
label="有效开始时间"
value={form.effectiveFrom}
onChange={(effectiveFrom) => page.setForm({ ...form, effectiveFrom })}
/>
<DateTimeField
disabled={disabled}
label="有效结束时间"
value={form.effectiveTo}
onChange={(effectiveTo) => page.setForm({ ...form, effectiveTo })}
/>
<GiftEffectSelect
disabled={disabled}
value={form.effectTypes}
onChange={(effectTypes) => page.setForm({ ...form, effectTypes })}
/>
<FormControlLabel
className={styles.giftSwitch}
control={
<Switch
checked={form.enabled}
disabled={disabled}
onChange={(event) => page.setForm({ ...form, enabled: event.target.checked })}
/>
}
label={form.enabled ? "启用" : "禁用"}
/>
</AdminFormFieldGrid>
<AdminFormSection title="资源信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={disabled || page.resourceOptionsLoading}
label="礼物资源"
required
select
value={form.resourceId}
onChange={(event) => page.selectGiftResource(event.target.value)}
>
{page.resourceOptions.map((resource) => (
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
{resource.name || resource.resourceCode || resource.resourceId}
</MenuItem>
))}
</TextField>
{showGiftIdentityFields ? (
<>
<TextField
disabled={disabled}
label="礼物名称"
required
value={form.name}
onChange={(event) => page.setForm({ ...form, name: event.target.value })}
/>
<TextField
disabled={disabled || mode === "edit"}
label="礼物 ID"
required
value={form.giftId}
onChange={(event) => page.setForm({ ...form, giftId: event.target.value })}
/>
</>
) : null}
<TextField
disabled={disabled}
label="礼物类型"
required
select
value={form.giftTypeCode}
onChange={(event) => page.setForm({ ...form, giftTypeCode: event.target.value })}
>
{giftTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="价格与排序">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={disabled}
label="收费类型"
required
select
value={form.chargeAssetType}
onChange={(event) => page.setForm({ ...form, chargeAssetType: event.target.value })}
>
{chargeAssetOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="价格"
required
type="number"
value={form.coinPrice}
onChange={(event) => page.changeGiftPrice(event.target.value)}
/>
<TextField
disabled={disabled}
label="积分"
type="number"
value={form.giftPointAmount}
onChange={(event) => page.setForm({ ...form, giftPointAmount: event.target.value })}
/>
<TextField
disabled={disabled}
label="热度"
type="number"
value={form.heatValue}
onChange={(event) => page.setForm({ ...form, heatValue: event.target.value })}
/>
<TextField
disabled={disabled}
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
/>
<AdminFormSwitchField
checked={form.enabled}
checkedLabel="启用"
className={styles.giftSwitch}
disabled={disabled}
label="礼物状态"
uncheckedLabel="禁用"
onChange={(checked) => page.setForm({ ...form, enabled: checked })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="投放与时间">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<GiftRegionSelect
disabled={disabled || page.loadingRegions}
labels={buildRegionLabelMap(regionOptions)}
options={regionOptions}
value={form.regionIds}
onChange={(regionIds) => page.setForm({ ...form, regionIds })}
/>
<DateTimeField
disabled={disabled}
label="有效开始时间"
value={form.effectiveFrom}
onChange={(effectiveFrom) => page.setForm({ ...form, effectiveFrom })}
/>
<DateTimeField
disabled={disabled}
label="有效结束时间"
value={form.effectiveTo}
onChange={(effectiveTo) => page.setForm({ ...form, effectiveTo })}
/>
<GiftEffectSelect
disabled={disabled}
value={form.effectTypes}
onChange={(effectTypes) => page.setForm({ ...form, effectTypes })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}

View File

@ -1,17 +1,16 @@
import Add from "@mui/icons-material/Add";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import {
@ -40,6 +39,12 @@ const grantColumns = [
width: "minmax(120px, 0.7fr)",
render: (grant) => grant.targetUserId || "-",
},
{
key: "operator",
label: "操作人",
width: "minmax(180px, 1fr)",
render: (grant) => <GrantOperator grant={grant} />,
},
{
key: "subject",
label: "对象",
@ -114,9 +119,6 @@ export function ResourceGrantListPage() {
return (
<AdminListPage>
<AdminListToolbar
filters={
<AdminFilterResetButton disabled={!page.query && !page.status} onClick={page.resetFilters} />
}
actions={
page.abilities.canCreateGrant ? (
<AdminActionIconButton label="资源赠送" primary onClick={page.openCreateGrant}>
@ -130,17 +132,19 @@ export function ResourceGrantListPage() {
<DataTable
columns={columns}
items={items}
minWidth="1190px"
minWidth="1370px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(grant) => grant.grantId}
/>
{total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</AdminListBody>
</DataState>
<ResourceGrantDialog
@ -181,82 +185,92 @@ function ResourceGrantDialog({
onClose={onClose}
onSubmit={onSubmit}
>
<TextField
disabled={disabled}
label="用户 ID"
required
type="number"
value={form.targetUserId}
onChange={(event) => setForm({ ...form, targetUserId: event.target.value })}
/>
<TextField
disabled={disabled}
label="对象类型"
required
select
value={form.subjectType}
onChange={(event) => setForm({ ...form, groupId: "", resourceId: "", subjectType: event.target.value })}
>
<MenuItem value="resource">资源</MenuItem>
<MenuItem value="resource_group">资源组</MenuItem>
</TextField>
{form.subjectType === "resource" ? (
<>
<AdminFormSection title="赠送对象">
<TextField
disabled={disabled}
label="用户 ID"
required
type="number"
value={form.targetUserId}
onChange={(event) => setForm({ ...form, targetUserId: event.target.value })}
/>
</AdminFormSection>
<AdminFormSection title="赠送内容">
<AdminFormFieldGrid>
<TextField
disabled={disabled || optionsLoading}
label="资源"
disabled={disabled}
label="对象类型"
required
select
value={form.resourceId}
onChange={(event) => setForm({ ...form, resourceId: event.target.value })}
value={form.subjectType}
onChange={(event) =>
setForm({ ...form, groupId: "", resourceId: "", subjectType: event.target.value })
}
>
{resourceOptions.map((resource) => (
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
{resource.name || resource.resourceCode || resource.resourceId}
</MenuItem>
))}
<MenuItem value="resource">资源</MenuItem>
<MenuItem value="resource_group">资源组</MenuItem>
</TextField>
<TextField
disabled={disabled}
label="数量"
required
type="number"
value={form.quantity}
onChange={(event) => setForm({ ...form, quantity: event.target.value })}
/>
<TextField
disabled={disabled}
label="有效天数"
type="number"
value={form.durationDays}
onChange={(event) => setForm({ ...form, durationDays: event.target.value })}
/>
</>
) : (
{form.subjectType === "resource" ? (
<>
<TextField
disabled={disabled || optionsLoading}
label="资源"
required
select
value={form.resourceId}
onChange={(event) => setForm({ ...form, resourceId: event.target.value })}
>
{resourceOptions.map((resource) => (
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
{resource.name || resource.resourceCode || resource.resourceId}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="数量"
required
type="number"
value={form.quantity}
onChange={(event) => setForm({ ...form, quantity: event.target.value })}
/>
<TextField
disabled={disabled}
label="有效天数"
type="number"
value={form.durationDays}
onChange={(event) => setForm({ ...form, durationDays: event.target.value })}
/>
</>
) : (
<TextField
disabled={disabled || optionsLoading}
label="资源组"
required
select
value={form.groupId}
onChange={(event) => setForm({ ...form, groupId: event.target.value })}
>
{groupOptions.map((group) => (
<MenuItem key={group.groupId} value={String(group.groupId)}>
{group.name || group.groupCode || group.groupId}
</MenuItem>
))}
</TextField>
)}
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="备注">
<TextField
disabled={disabled || optionsLoading}
label="资源组"
disabled={disabled}
label="原因"
multiline
minRows={2}
required
select
value={form.groupId}
onChange={(event) => setForm({ ...form, groupId: event.target.value })}
>
{groupOptions.map((group) => (
<MenuItem key={group.groupId} value={String(group.groupId)}>
{group.name || group.groupCode || group.groupId}
</MenuItem>
))}
</TextField>
)}
<TextField
disabled={disabled}
label="原因"
multiline
minRows={2}
required
value={form.reason}
onChange={(event) => setForm({ ...form, reason: event.target.value })}
/>
value={form.reason}
onChange={(event) => setForm({ ...form, reason: event.target.value })}
/>
</AdminFormSection>
</AdminFormDialog>
);
}
@ -279,6 +293,49 @@ function GrantItems({ grant }) {
);
}
function GrantOperator({ grant }) {
const operator = grant.operator || {};
const source = operator.source || grant.grantSource || "";
const operatorUserId = operator.userId || grant.operatorUserId;
if (source === "manager_center") {
const name = operator.username || operator.name || (operatorUserId ? `用户 ${operatorUserId}` : "-");
const shortId = operator.displayUserId || operatorUserId || "-";
return (
<div className={styles.identity}>
<span className={styles.operatorAvatar}>
{operator.avatar ? (
<img alt="" src={operator.avatar} />
) : (
<PersonOutlineOutlined fontSize="small" />
)}
</span>
<span className={styles.identityText}>
<span className={styles.name}>{name}</span>
<span className={styles.meta}>{shortId}</span>
</span>
</div>
);
}
if (source === "admin") {
const name = operator.name || operator.username || (operatorUserId ? `Admin ${operatorUserId}` : "-");
return (
<div className={styles.stack}>
<span className={styles.name}>{name}</span>
<span className={styles.meta}>{operator.username || operatorUserId || "-"}</span>
</div>
);
}
if (!operatorUserId) {
return "-";
}
return (
<div className={styles.stack}>
<span className={styles.name}>{operatorUserId}</span>
<span className={styles.meta}>{source || "-"}</span>
</div>
);
}
function formatDuration(durationMs) {
const days = Number(durationMs || 0) / (24 * 60 * 60 * 1000);
return Number.isInteger(days) ? `${days}` : `${days.toFixed(1)}`;

View File

@ -5,9 +5,8 @@ import DiamondOutlined from "@mui/icons-material/DiamondOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import FormControlLabel from "@mui/material/FormControlLabel";
import MenuItem from "@mui/material/MenuItem";
import Switch from "@mui/material/Switch";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import {
AdminFormDialog,
@ -16,19 +15,18 @@ import {
AdminFormList,
AdminFormListRow,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import { resourceGroupAssetLabels, resourceStatusFilters, resourceTypeLabels } from "@/features/resources/constants.js";
@ -110,9 +108,6 @@ export function ResourceGroupListPage() {
return (
<AdminListPage>
<AdminListToolbar
filters={
<AdminFilterResetButton disabled={!page.query && !page.status} onClick={page.resetFilters} />
}
actions={
page.abilities.canCreateGroup ? (
<AdminActionIconButton label="添加资源组" primary onClick={page.openCreateGroup}>
@ -123,15 +118,22 @@ export function ResourceGroupListPage() {
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable columns={columns} items={items} minWidth="1060px" rowKey={(group) => group.groupId} />
{total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
<DataTable
columns={columns}
items={items}
minWidth="1060px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(group) => group.groupId}
/>
</AdminListBody>
</DataState>
<ResourceGroupFormDialog
@ -171,7 +173,7 @@ function ResourceGroupRowActions({ group, page }) {
function ResourceGroupStatusSwitch({ group, page }) {
const checked = group.status === "active";
return (
<Switch
<AdminSwitch
checked={checked}
disabled={!page.abilities.canUpdateGroup || page.loadingAction === `group-status-${group.groupId}`}
inputProps={{ "aria-label": checked ? "禁用资源组" : "启用资源组" }}
@ -192,62 +194,67 @@ function ResourceGroupFormDialog({ disabled, form, loading, mode, onClose, onSub
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormFieldGrid columns="minmax(210px, 1fr) minmax(210px, 1fr) 112px">
<TextField
label="资源组名称"
required
value={form.name}
onChange={(event) => page.setForm({ ...form, name: event.target.value })}
/>
<TextField
label="资源组编码"
required
value={form.groupCode}
onChange={(event) => page.setForm({ ...form, groupCode: event.target.value })}
/>
<TextField
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
/>
</AdminFormFieldGrid>
<TextField
label="说明"
multiline
minRows={2}
value={form.description}
onChange={(event) => page.setForm({ ...form, description: event.target.value })}
/>
<FormControlLabel
control={
<Switch
checked={form.enabled}
onChange={(event) => page.setForm({ ...form, enabled: event.target.checked })}
<AdminFormSection title="基础信息">
<AdminFormFieldGrid columns="minmax(210px, 1fr) minmax(210px, 1fr) 112px">
<TextField
disabled={disabled}
label="资源组名称"
required
value={form.name}
onChange={(event) => page.setForm({ ...form, name: event.target.value })}
/>
}
label={form.enabled ? "启用" : "禁用"}
/>
<TextField
disabled={disabled}
label="资源组编码"
required
value={form.groupCode}
onChange={(event) => page.setForm({ ...form, groupCode: event.target.value })}
/>
<TextField
disabled={disabled}
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
/>
</AdminFormFieldGrid>
<TextField
disabled={disabled}
label="说明"
multiline
minRows={2}
value={form.description}
onChange={(event) => page.setForm({ ...form, description: event.target.value })}
/>
<AdminFormSwitchField
checked={form.enabled}
checkedLabel="启用"
disabled={disabled}
label="资源组状态"
uncheckedLabel="禁用"
onChange={(checked) => page.setForm({ ...form, enabled: checked })}
/>
</AdminFormSection>
<AdminFormSection
title="组内资源"
actions={
<AdminFormInlineActions>
<Button
disabled={loading}
disabled={disabled || loading}
onClick={page.addResourceItem}
startIcon={<Inventory2Outlined fontSize="small" />}
>
资源
</Button>
<Button
disabled={loading}
disabled={disabled || loading}
onClick={() => page.addWalletAssetItem("COIN")}
startIcon={<MonetizationOnOutlined fontSize="small" />}
>
金币
</Button>
<Button
disabled={loading}
disabled={disabled || loading}
onClick={() => page.addWalletAssetItem("DIAMOND")}
startIcon={<DiamondOutlined fontSize="small" />}
>
@ -259,7 +266,7 @@ function ResourceGroupFormDialog({ disabled, form, loading, mode, onClose, onSub
<AdminFormList>
{form.items.map((item, index) => (
<ResourceGroupItemEditor
disabled={loading}
disabled={disabled || loading}
index={index}
item={item}
key={`${item.itemType}-${item.walletAssetType || item.resourceId || index}-${index}`}

View File

@ -1,23 +1,25 @@
import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import FormControlLabel from "@mui/material/FormControlLabel";
import MenuItem from "@mui/material/MenuItem";
import Switch from "@mui/material/Switch";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminRowActions,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import {
@ -121,12 +123,6 @@ export function ResourceListPage() {
return (
<AdminListPage>
<AdminListToolbar
filters={
<AdminFilterResetButton
disabled={!page.query && !page.status && !page.resourceType}
onClick={page.resetFilters}
/>
}
actions={
page.abilities.canCreate ? (
<AdminActionIconButton label="添加资源" primary onClick={page.openCreateResource}>
@ -141,16 +137,18 @@ export function ResourceListPage() {
columns={columns}
items={items}
minWidth="1120px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(resource) => resource.resourceId}
/>
{total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</AdminListBody>
</DataState>
<ResourceFormDialog
@ -197,61 +195,74 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
onClose={onClose}
onSubmit={onSubmit}
>
<TextField
label="资源名称"
required
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
/>
<TextField
label="资源编码"
required
value={form.resourceCode}
onChange={(event) => setForm({ ...form, resourceCode: event.target.value })}
/>
<TextField
label="资源类型"
required
select
value={form.resourceType}
onChange={(event) => setForm({ ...form, resourceType: event.target.value, walletAssetAmount: "" })}
>
{resourceTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
{form.resourceType === "coin" ? (
<TextField
label="金币数量"
required
type="number"
value={form.walletAssetAmount}
onChange={(event) => setForm({ ...form, walletAssetAmount: event.target.value })}
/>
) : null}
<UploadField
disabled={uploadDisabled}
label="资源封面"
value={form.previewUrl}
onChange={(previewUrl) => setForm({ ...form, previewUrl })}
/>
<UploadField
disabled={uploadDisabled}
label="资源素材"
value={form.assetUrl}
onChange={(assetUrl) => setForm({ ...form, assetUrl })}
/>
<FormControlLabel
control={
<Switch
checked={form.enabled}
onChange={(event) => setForm({ ...form, enabled: event.target.checked })}
<AdminFormSection title="基础信息">
<AdminFormFieldGrid>
<TextField
disabled={disabled}
label="资源名称"
required
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
/>
}
label={form.enabled ? "启用" : "禁用"}
/>
<TextField
disabled={disabled}
label="资源编码"
required
value={form.resourceCode}
onChange={(event) => setForm({ ...form, resourceCode: event.target.value })}
/>
<TextField
disabled={disabled}
label="资源类型"
required
select
value={form.resourceType}
onChange={(event) => setForm({ ...form, resourceType: event.target.value, walletAssetAmount: "" })}
>
{resourceTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
{form.resourceType === "coin" ? (
<TextField
disabled={disabled}
label="金币数量"
required
type="number"
value={form.walletAssetAmount}
onChange={(event) => setForm({ ...form, walletAssetAmount: event.target.value })}
/>
) : null}
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="素材">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<UploadField
disabled={disabled || uploadDisabled}
label="资源封面"
value={form.previewUrl}
onChange={(previewUrl) => setForm({ ...form, previewUrl })}
/>
<UploadField
disabled={disabled || uploadDisabled}
label="资源素材"
value={form.assetUrl}
onChange={(assetUrl) => setForm({ ...form, assetUrl })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="状态">
<AdminFormSwitchField
checked={form.enabled}
checkedLabel="启用"
disabled={disabled}
label="资源状态"
uncheckedLabel="禁用"
onChange={(checked) => setForm({ ...form, enabled: checked })}
/>
</AdminFormSection>
</AdminFormDialog>
);
}
@ -289,7 +300,7 @@ function GrantInfo({ resource }) {
function ResourceStatusSwitch({ page, resource }) {
const checked = resource.status === "active";
return (
<Switch
<AdminSwitch
checked={checked}
disabled={!page.abilities.canUpdate || page.loadingAction === `resource-status-${resource.resourceId}`}
inputProps={{ "aria-label": checked ? "禁用资源" : "启用资源" }}

View File

@ -25,6 +25,26 @@
object-fit: cover;
}
.operatorAvatar {
display: inline-flex;
width: 34px;
height: 34px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--bg-card-strong);
color: var(--text-secondary);
}
.operatorAvatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.identityText,
.stack {
display: grid;

View File

@ -1,6 +1,6 @@
import { z } from "zod";
const resourceTypes = ["avatar_frame", "coin", "vehicle", "chat_bubble", "badge", "floating_screen", "gift"];
const resourceTypes = ["avatar_frame", "profile_card", "coin", "vehicle", "chat_bubble", "badge", "floating_screen", "gift"];
const walletAssetTypes = ["COIN", "DIAMOND"];
const optionalWalletAssetTypeSchema = z
.preprocess((value) => {

View File

@ -1,8 +1,22 @@
import { describe, expect, test } from "vitest";
import { FormValidationError, parseForm } from "@/shared/forms/validation";
import { giftFormSchema, resourceGrantFormSchema, resourceGroupCreateFormSchema } from "./schema.js";
import { giftFormSchema, resourceFormSchema, resourceGrantFormSchema, resourceGroupCreateFormSchema } from "./schema.js";
describe("resource form schema", () => {
test("allows profile card resources", () => {
const payload = parseForm(resourceFormSchema, {
assetUrl: "https://media.haiyihy.com/resource/profile-card.png",
enabled: true,
name: "Profile Card",
previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png",
resourceCode: "profile_card_gold",
resourceType: "profile_card",
walletAssetAmount: ""
});
expect(payload.resourceType).toBe("profile_card");
});
test("allows resource group resource items without wallet asset type", () => {
const payload = parseForm(resourceGroupCreateFormSchema, {
description: "",

View File

@ -19,22 +19,30 @@ export function RoleFormDrawer({
<Drawer anchor="right" open={open} onClose={onClose}>
<form className="form-drawer form-drawer--wide" onSubmit={onSubmit}>
<h2>{editingRole ? "编辑角色" : "新增角色"}</h2>
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="角色名称" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="角色编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="说明" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
{abilities.canLoadPermissions ? (
<div className="permission-menu-list">
{permissionGroups.map((group) => (
<PermissionMenuGroup
abilities={abilities}
group={group}
key={group.key}
permissionIds={form.permissionIds}
onTogglePermission={onTogglePermission}
onTogglePermissionGroup={onTogglePermissionGroup}
/>
))}
<section className="form-drawer__section">
<div className="form-drawer__section-title">基础信息</div>
<div className="form-drawer__grid">
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="角色名称" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="角色编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="说明" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
</div>
</section>
{abilities.canLoadPermissions ? (
<section className="form-drawer__section">
<div className="form-drawer__section-title">权限范围</div>
<div className="permission-menu-list">
{permissionGroups.map((group) => (
<PermissionMenuGroup
abilities={abilities}
group={group}
key={group.key}
permissionIds={form.permissionIds}
onTogglePermission={onTogglePermission}
onTogglePermissionGroup={onTogglePermissionGroup}
/>
))}
</div>
</section>
) : null}
<div className="form-drawer__actions">
<Button onClick={onClose}>取消</Button>

View File

@ -25,11 +25,11 @@ const permissionGroupDefinitions = [
{ key: "rooms", prefixes: ["room"], title: "房间管理" },
{ key: "countries", prefixes: ["country"], title: "国家管理" },
{ key: "regions", prefixes: ["region"], title: "区域管理" },
{ key: "agencies", prefixes: ["agency"], title: "Agency 管理" },
{ key: "bds", prefixes: ["bd"], title: "BD 管理" },
{ key: "hosts", prefixes: ["host"], title: "主播管理" },
{ key: "agencies", prefixes: ["agency"], title: "Agency 列表" },
{ key: "bds", prefixes: ["bd"], title: "BD 列表" },
{ key: "hosts", prefixes: ["host"], title: "主播列表" },
{ key: "coin-sellers", prefixes: ["coin-seller"], title: "币商列表" },
{ key: "logs", prefixes: ["log"], title: "日志审计" },
{ key: "notifications", prefixes: ["notification"], title: "通知中心" },
{ key: "jobs", prefixes: ["job", "export"], title: "任务中心" },
{ key: "common", prefixes: ["upload"], title: "通用能力" },
];

View File

@ -2,7 +2,6 @@ import Add from "@mui/icons-material/Add";
import { DataState } from "@/shared/ui/DataState.jsx";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
@ -18,9 +17,6 @@ export function RoleManagementPage() {
<>
<AdminListPage>
<AdminListToolbar
filters={
<AdminFilterResetButton disabled={!page.query} onClick={page.resetFilters} />
}
actions={
page.abilities.canCreate ? (
<AdminActionIconButton label="新增角色" primary onClick={page.openCreate}>

View File

@ -4,15 +4,17 @@ import type {
ApiPage,
EntityId,
PageQuery,
RoomConfigDto,
RoomConfigPayload,
RoomDto,
RoomUpdatePayload
RoomUpdatePayload,
} from "@/shared/api/types";
export function listRooms(query: PageQuery = {}): Promise<ApiPage<RoomDto>> {
const endpoint = API_ENDPOINTS.listRooms;
return apiRequest<ApiPage<RoomDto>>(apiEndpointPath(API_OPERATIONS.listRooms), {
method: endpoint.method,
query
query,
});
}
@ -20,13 +22,28 @@ export function updateRoom(roomId: EntityId, payload: RoomUpdatePayload): Promis
const endpoint = API_ENDPOINTS.updateRoom;
return apiRequest<RoomDto, RoomUpdatePayload>(apiEndpointPath(API_OPERATIONS.updateRoom, { room_id: roomId }), {
body: payload,
method: endpoint.method
method: endpoint.method,
});
}
export function deleteRoom(roomId: EntityId): Promise<{ deleted: boolean }> {
const endpoint = API_ENDPOINTS.deleteRoom;
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteRoom, { room_id: roomId }), {
method: endpoint.method
method: endpoint.method,
});
}
export function getRoomConfig(): Promise<RoomConfigDto> {
const endpoint = API_ENDPOINTS.getRoomConfig;
return apiRequest<RoomConfigDto>(apiEndpointPath(API_OPERATIONS.getRoomConfig), {
method: endpoint.method,
});
}
export function updateRoomConfig(payload: RoomConfigPayload): Promise<RoomConfigDto> {
const endpoint = API_ENDPOINTS.updateRoomConfig;
return apiRequest<RoomConfigDto, RoomConfigPayload>(apiEndpointPath(API_OPERATIONS.updateRoomConfig), {
body: payload,
method: endpoint.method,
});
}

View File

@ -1,5 +1,7 @@
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useEffect, useState } from "react";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { roomStatusLabels } from "@/features/rooms/constants.js";
@ -8,7 +10,7 @@ import styles from "@/features/rooms/rooms.module.css";
export function RoomDetailDrawer({ onClose, open, room }) {
const owner = room?.owner || {};
const normalizedStatus = room?.status === "active" ? "active" : "closed";
const ownerDisplayId = owner.displayUserId || room?.ownerUserId || "-";
const ownerDisplayId = ownerLongShortId(owner, room);
return (
<SideDrawer open={open} title="房间详情" width="wide" onClose={onClose}>
@ -23,18 +25,17 @@ export function RoomDetailDrawer({ onClose, open, room }) {
<h3>{room.title || "-"}</h3>
<StatusBadge status={normalizedStatus} />
</div>
<span className={styles.detailMeta}>房间 ID {room.roomId}</span>
<CopyableRoomId className={styles.detailMeta} prefix="房间 ID " roomId={room.roomId} />
</div>
</header>
<DetailSection
items={[
["房间名称", room.title],
["房间 ID", room.roomId],
["房间 ID", <CopyableRoomId key="room-id" roomId={room.roomId} />],
["房间状态", roomStatusLabels[normalizedStatus]],
["房间模式", room.mode],
["区域", room.regionName || formatRegion(room.visibleRegionId)],
["可见区域 ID", room.visibleRegionId],
["区域", room.regionName],
["创建时间", formatMillis(room.createdAtMs)],
["更新时间", formatMillis(room.updatedAtMs)]
]}
@ -53,8 +54,7 @@ export function RoomDetailDrawer({ onClose, open, room }) {
</div>
</div>
<div className={styles.detailGrid}>
<DetailItem label="房主用户 ID" value={room.ownerUserId || owner.userId} />
<DetailItem label="主持用户 ID" value={room.hostUserId} />
<DetailItem label="房主长 ID(短 ID)" value={ownerDisplayId} />
</div>
</section>
@ -63,6 +63,7 @@ export function RoomDetailDrawer({ onClose, open, room }) {
["在线人数", `${formatNumber(room.onlineCount)}`],
["座位数", `${formatNumber(room.seatCount)}`],
["已占座位", `${formatNumber(room.occupiedSeatCount)}`],
["房间贡献", formatNumber(roomContributionValue(room))],
["热度", formatNumber(room.heat)],
["排序分", formatNumber(room.sortScore)]
]}
@ -91,11 +92,46 @@ function DetailItem({ label, value }) {
return (
<div className={styles.detailItem}>
<span className={styles.detailLabel}>{label}</span>
<strong className={styles.detailValue}>{displayValue(value)}</strong>
<div className={styles.detailValue}>{displayValue(value)}</div>
</div>
);
}
function CopyableRoomId({ className = "", prefix = "", roomId }) {
const [copied, setCopied] = useState(false);
const value = displayValue(roomId);
useEffect(() => {
if (!copied) {
return undefined;
}
const timer = window.setTimeout(() => setCopied(false), 1200);
return () => window.clearTimeout(timer);
}, [copied]);
const copyRoomId = async () => {
if (!roomId) {
return;
}
await copyText(String(roomId));
setCopied(true);
};
return (
<button
aria-label={`复制房间 ID ${value}`}
className={[styles.copyValue, className].filter(Boolean).join(" ")}
disabled={!roomId}
type="button"
onClick={copyRoomId}
>
<span>{prefix}{value}</span>
<ContentCopyOutlined className={styles.copyIcon} fontSize="inherit" />
{copied ? <span className={styles.copyState}>已复制</span> : null}
</button>
);
}
function StatusBadge({ status }) {
const tone = status === "active" ? "running" : "stopped";
@ -115,6 +151,36 @@ function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}
function formatRegion(value) {
return value ? `区域 ${value}` : "-";
function roomContributionValue(room) {
return room?.roomContribution ?? room?.heat ?? 0;
}
async function copyText(value) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return;
} catch {
// Clipboard API退
}
}
const input = document.createElement("textarea");
input.value = value;
input.setAttribute("readonly", "");
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
input.remove();
}
function ownerLongShortId(owner, room) {
const longId = owner.userId || room?.ownerUserId || "";
const shortId = owner.displayUserId || "";
if (longId && shortId && longId !== shortId) {
return `${longId}(${shortId})`;
}
return longId || shortId || "-";
}

View File

@ -0,0 +1,109 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { getRoomConfig, updateRoomConfig } from "@/features/rooms/api";
import { useRoomAbilities } from "@/features/rooms/permissions.js";
import { roomConfigSchema } from "@/features/rooms/schema";
const fallbackCandidates = [10, 15, 20, 25, 30];
const emptyData = {
allowedSeatCounts: fallbackCandidates,
candidateSeatCounts: fallbackCandidates,
defaultSeatCount: 15,
updatedAtMs: 0,
};
export function useRoomConfigPage() {
const abilities = useRoomAbilities();
const { showToast } = useToast();
const [form, setForm] = useState(emptyData);
const [loadingAction, setLoadingAction] = useState("");
const queryFn = useCallback(() => getRoomConfig(), []);
const {
data = emptyData,
error,
loading,
reload,
} = useAdminQuery(queryFn, {
errorMessage: "加载房间配置失败",
initialData: emptyData,
queryKey: ["rooms", "config"],
});
useEffect(() => {
if (!data) {
return;
}
setForm({
allowedSeatCounts: normalizeNumbers(data.allowedSeatCounts),
candidateSeatCounts: normalizeNumbers(data.candidateSeatCounts),
defaultSeatCount: Number(data.defaultSeatCount || 15),
updatedAtMs: data.updatedAtMs || 0,
});
}, [data]);
const allowedSet = useMemo(() => new Set(form.allowedSeatCounts), [form.allowedSeatCounts]);
const toggleSeatCount = (seatCount) => {
setForm((current) => {
const currentAllowed = new Set(current.allowedSeatCounts);
if (currentAllowed.has(seatCount)) {
currentAllowed.delete(seatCount);
} else {
currentAllowed.add(seatCount);
}
const allowedSeatCounts = [...currentAllowed].sort((left, right) => left - right);
const defaultSeatCount = allowedSeatCounts.includes(current.defaultSeatCount)
? current.defaultSeatCount
: (allowedSeatCounts[0] ?? current.defaultSeatCount);
return { ...current, allowedSeatCounts, defaultSeatCount };
});
};
const changeDefaultSeatCount = (value) => {
setForm((current) => ({ ...current, defaultSeatCount: Number(value) }));
};
const submitConfig = async (event) => {
event.preventDefault();
setLoadingAction("save");
try {
const payload = parseForm(roomConfigSchema, {
allowedSeatCounts: form.allowedSeatCounts,
defaultSeatCount: form.defaultSeatCount,
});
const saved = await updateRoomConfig(payload);
setForm({
allowedSeatCounts: normalizeNumbers(saved.allowedSeatCounts),
candidateSeatCounts: normalizeNumbers(saved.candidateSeatCounts),
defaultSeatCount: Number(saved.defaultSeatCount || 15),
updatedAtMs: saved.updatedAtMs || 0,
});
await reload();
showToast("房间配置已保存", "success");
} catch (err) {
showToast(err.message || "保存失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
allowedSet,
changeDefaultSeatCount,
data,
error,
form,
loading,
loadingAction,
reload,
submitConfig,
toggleSeatCount,
};
}
function normalizeNumbers(values) {
return Array.isArray(values) ? values.map((value) => Number(value)).filter(Number.isFinite) : [];
}

Some files were not shown because too many files have changed in this diff Show More