diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5745caf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.git +.DS_Store +coverage +run +*.log diff --git a/AGENTS.md b/AGENTS.md index 027c99c..9cf84e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,7 @@ import { Refresh } from "@mui/icons-material"; - 表格空态只显示 `当前无数据`,创建入口放在页面工具栏,不放在空态里。 - 表格里的启用/禁用状态优先使用行内 Switch 快捷操作,不使用单独的启用/禁用图标按钮。 - 表格状态筛选使用 Select 下拉框,默认值为 `全部状态`,不使用状态 chip 组。 +- 后台列表页的时间区间筛选必须使用统一的单入口时间区间筛选框(`src/shared/ui/TimeRangeFilter.jsx`);不要在工具栏里并排放两个开始/结束时间输入框,也不要使用浏览器原生日期/时间输入样式。时间区间弹层内使用快捷项、日历和时分下拉点选,不使用手写时间文本框。 - 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。 - 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。 - 图片上传表单必须提供统一预览;`webp` 使用浏览器原生动效预览,`svga` 使用 SVGA 播放器预览,`pag` 使用 libpag + wasm 预览。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..74a86df --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node:24-alpine AS builder + +WORKDIR /src + +RUN corepack enable + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY . . + +ARG VITE_API_BASE_URL=/api +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} + +RUN pnpm build + +FROM nginx:1.27-alpine + +COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /src/dist /usr/share/nginx/html + +EXPOSE 80 diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 5b0ff2b..e8a0230 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -84,6 +84,46 @@ ] } }, + "/admin/activity/registration-reward/claims": { + "get": { + "operationId": "listRegistrationRewardClaims", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "registration-reward:view", + "x-permissions": [ + "registration-reward:view" + ] + } + }, + "/admin/activity/registration-reward/config": { + "get": { + "operationId": "getRegistrationRewardConfig", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "registration-reward:view", + "x-permissions": [ + "registration-reward:view" + ] + }, + "put": { + "operationId": "updateRegistrationRewardConfig", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "registration-reward:update", + "x-permissions": [ + "registration-reward:update" + ] + } + }, "/admin/agencies": { "get": { "operationId": "listAgencies", @@ -276,6 +316,78 @@ ] } }, + "/admin/app-config/versions": { + "get": { + "operationId": "listAppVersions", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "app-version:view", + "x-permissions": [ + "app-version:view" + ] + }, + "post": { + "operationId": "createAppVersion", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "app-version:create", + "x-permissions": [ + "app-version:create" + ] + } + }, + "/admin/app-config/versions/{version_id}": { + "put": { + "operationId": "updateAppVersion", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "version_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "app-version:update", + "x-permissions": [ + "app-version:update" + ] + }, + "delete": { + "operationId": "deleteAppVersion", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "version_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "app-version:delete", + "x-permissions": [ + "app-version:delete" + ] + } + }, "/admin/apps": { "get": { "operationId": "listApps", @@ -691,6 +803,130 @@ ] } }, + "/admin/game/games": { + "get": { + "operationId": "listCatalog", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "game:view", + "x-permissions": [ + "game:view" + ] + }, + "post": { + "operationId": "createCatalog", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "game:create", + "x-permissions": [ + "game:create" + ] + } + }, + "/admin/game/games/{game_id}": { + "patch": { + "operationId": "updateCatalog", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "game_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "game:update", + "x-permissions": [ + "game:update" + ] + } + }, + "/admin/game/games/{game_id}/status": { + "patch": { + "operationId": "setGameStatus", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "game_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "game:status", + "x-permissions": [ + "game:status" + ] + } + }, + "/admin/game/platforms": { + "get": { + "operationId": "listPlatforms", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "game:view", + "x-permissions": [ + "game:view" + ] + }, + "post": { + "operationId": "createPlatform", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "game:update", + "x-permissions": [ + "game:update" + ] + } + }, + "/admin/game/platforms/{platform_code}": { + "patch": { + "operationId": "updatePlatform", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "platform_code", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "game:update", + "x-permissions": [ + "game:update" + ] + } + }, "/admin/gifts": { "get": { "operationId": "listGifts", @@ -823,6 +1059,20 @@ ] } }, + "/admin/operations/coin-ledger": { + "get": { + "operationId": "listCoinLedger", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "coin-ledger:view", + "x-permissions": [ + "coin-ledger:view" + ] + } + }, "/admin/payment/recharge-bills": { "get": { "operationId": "listRechargeBills", @@ -837,6 +1087,78 @@ ] } }, + "/admin/payment/recharge-products": { + "get": { + "operationId": "listRechargeProducts", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "payment-product:view", + "x-permissions": [ + "payment-product:view" + ] + }, + "post": { + "operationId": "createRechargeProduct", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "payment-product:create", + "x-permissions": [ + "payment-product:create" + ] + } + }, + "/admin/payment/recharge-products/{product_id}": { + "put": { + "operationId": "updateRechargeProduct", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "product_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "payment-product:update", + "x-permissions": [ + "payment-product:update" + ] + }, + "delete": { + "operationId": "deleteRechargeProduct", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "product_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "payment-product:delete", + "x-permissions": [ + "payment-product:delete" + ] + } + }, "/admin/regions": { "get": { "operationId": "listRegions", @@ -1345,6 +1667,32 @@ ] } }, + "/admin/rooms/config": { + "get": { + "operationId": "getRoomConfig", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "room-config:view", + "x-permissions": [ + "room-config:view" + ] + }, + "put": { + "operationId": "updateRoomConfig", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "room-config:update", + "x-permissions": [ + "room-config:update" + ] + } + }, "/app/users": { "get": { "operationId": "appListUsers", @@ -1471,6 +1819,20 @@ ] } }, + "/app/users/bans": { + "get": { + "operationId": "appListBannedUsers", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "app-user:view", + "x-permissions": [ + "app-user:view" + ] + } + }, "/app/users/login-logs": { "get": { "operationId": "appListLoginLogs", @@ -1709,72 +2071,6 @@ } } }, - "/notifications": { - "get": { - "operationId": "listNotifications", - "x-permission": "notification:view", - "responses": { - "200": { - "$ref": "#/components/responses/NotificationListResponse" - } - }, - "x-permissions": [ - "notification:view" - ] - } - }, - "/notifications/{id}": { - "delete": { - "operationId": "deleteNotification", - "x-permission": "notification:delete", - "parameters": [ - { - "$ref": "#/components/parameters/Id" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/EmptyResponse" - } - }, - "x-permissions": [ - "notification:delete" - ] - } - }, - "/notifications/{id}/read": { - "patch": { - "operationId": "markNotificationRead", - "x-permission": "notification:read", - "parameters": [ - { - "$ref": "#/components/parameters/Id" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/NotificationResponse" - } - }, - "x-permissions": [ - "notification:read" - ] - } - }, - "/notifications/read-all": { - "patch": { - "operationId": "markAllNotificationsRead", - "x-permission": "notification:read-all", - "responses": { - "200": { - "$ref": "#/components/responses/NotificationListResponse" - } - }, - "x-permissions": [ - "notification:read-all" - ] - } - }, "/permissions": { "get": { "operationId": "listPermissions", @@ -2623,26 +2919,6 @@ } } }, - "NotificationListResponse": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponseNotificationList" - } - } - } - }, - "NotificationResponse": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponseNotification" - } - } - } - }, "PermissionListResponse": { "description": "OK", "content": { @@ -2991,36 +3267,6 @@ } ] }, - "ApiResponseNotification": { - "allOf": [ - { - "$ref": "#/components/schemas/Envelope" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/components/schemas/Notification" - } - } - } - ] - }, - "ApiResponseNotificationList": { - "allOf": [ - { - "$ref": "#/components/schemas/Envelope" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/components/schemas/NotificationList" - } - } - } - ] - }, "ApiResponsePermission": { "allOf": [ { @@ -3357,28 +3603,6 @@ ], "additionalProperties": true }, - "Notification": { - "type": "object", - "additionalProperties": true - }, - "NotificationList": { - "type": "object", - "required": [ - "items", - "unread" - ], - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Notification" - } - }, - "unread": { - "type": "integer" - } - } - }, "Permission": { "type": "object", "additionalProperties": true diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..2801a31 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,34 @@ +server { + listen 80; + server_name hyapp-platform.global-interaction.com _; + + root /usr/share/nginx/html; + index index.html; + + location = /__frontend_healthz { + access_log off; + add_header Content-Type text/plain; + return 200 "ok\n"; + } + + location /api/ { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://127.0.0.1:13100/api/; + } + + location = /healthz { + proxy_pass http://127.0.0.1:13100/healthz; + } + + location = /readyz { + proxy_pass http://127.0.0.1:13100/readyz; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/src/app/layout/Header.jsx b/src/app/layout/Header.jsx index 195719f..a566c09 100644 --- a/src/app/layout/Header.jsx +++ b/src/app/layout/Header.jsx @@ -4,7 +4,6 @@ import LockReset from "@mui/icons-material/LockReset"; import Logout from "@mui/icons-material/Logout"; import Menu from "@mui/icons-material/Menu"; import MenuOpen from "@mui/icons-material/MenuOpen"; -import NotificationsNoneOutlined from "@mui/icons-material/NotificationsNoneOutlined"; import Person from "@mui/icons-material/Person"; import Refresh from "@mui/icons-material/Refresh"; import Search from "@mui/icons-material/Search"; @@ -15,10 +14,7 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx"; import { useAuth } from "@/app/auth/AuthProvider.jsx"; -import { PERMISSIONS } from "@/app/permissions"; import { changePassword } from "@/features/auth/api"; -import { listNotifications } from "@/features/notifications/api"; -import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; @@ -35,19 +31,11 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr const [passwordError, setPasswordError] = useState(""); const [changingPassword, setChangingPassword] = useState(false); const navigate = useNavigate(); - const { can, logout, user } = useAuth(); + const { logout, user } = useAuth(); const appScope = useAppScope(); const { showToast } = useToast(); const ToggleIcon = isSidebarCollapsed ? MenuOpen : Menu; const userMenuOpen = Boolean(userMenuAnchor); - const canViewNotifications = can(PERMISSIONS.notificationView); - const { data: notificationState } = useAdminQuery(() => listNotifications(), { - enabled: canViewNotifications, - initialData: { items: [], unread: 0 }, - queryKey: ["notifications", "unread"], - refetchInterval: 30000 - }); - const unread = notificationState?.unread || 0; useEffect(() => { if (searchOpen) { @@ -136,10 +124,6 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr } }; - const openNotifications = () => { - navigate("/notifications"); - }; - const refresh = () => { onRefresh(); showToast("已刷新当前页面", "success"); @@ -191,11 +175,6 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr - {canViewNotifications ? ( - - - - ) : null} + ) : null + } + /> + + + item.id} /> +
+ {items.length} 条 +
+
+
+ + + + + page.setForm({ platform: event.target.value })} + > + Android + iOS + + page.setForm({ version: event.target.value })} + /> + page.setForm({ buildNumber: event.target.value })} + /> + page.setForm({ forceUpdate: checked })} + /> + + + + page.setForm({ downloadUrl: event.target.value })} + /> + page.setForm({ description: event.target.value })} + /> + + + + ); +} + +function versionColumns(page) { + const columns = [ + { + key: "version", + label: "版本", + render: (item) => , + filter: createTextColumnFilter({ + placeholder: "搜索版本、链接、描述", + value: page.keyword, + onChange: page.setKeyword + }), + width: "minmax(190px, 0.9fr)" + }, + { + key: "platform", + label: "平台", + render: (item) => platformLabel(item.platform), + filter: createOptionsColumnFilter({ + options: platformOptions, + placeholder: "搜索平台", + value: page.platform, + onChange: page.setPlatform + }), + width: "minmax(120px, 0.5fr)" + }, + { + key: "forceUpdate", + label: "强更", + render: (item) => , + width: "minmax(110px, 0.45fr)" + }, + { + key: "downloadUrl", + label: "下载链接", + render: (item) => {item.downloadUrl}, + width: "minmax(280px, 1.25fr)" + }, + { + key: "description", + label: "更新描述", + render: (item) => item.description || "-", + width: "minmax(220px, 1fr)" + }, + { + key: "updatedAtMs", + label: "更新时间", + render: (item) => formatMillis(item.updatedAtMs), + width: "minmax(160px, 0.7fr)" + } + ]; + + if (!page.abilities.canUpdateVersion && !page.abilities.canDeleteVersion) { + return columns; + } + + return [ + ...columns, + { + key: "actions", + label: "操作", + render: (item) => , + width: "minmax(96px, 0.42fr)" + } + ]; +} + +function VersionActions({ item, page }) { + const deleting = page.loadingAction === `delete:${item.id}`; + return ( + + {page.abilities.canUpdateVersion ? ( + page.openEdit(item)}> + + + ) : null} + {page.abilities.canDeleteVersion ? ( + page.removeVersion(item)}> + + + ) : null} + + ); +} + +function Stack({ primary, secondary }) { + return ( +
+ {primary || "-"} + {secondary || "-"} +
+ ); +} + +function ForceBadge({ enabled }) { + const tone = enabled ? "warning" : "stopped"; + return ( + + + {enabled ? "强更" : "普通"} + + ); +} + +function platformLabel(value) { + if (value === "android") { + return "Android"; + } + if (value === "ios") { + return "iOS"; + } + return value || "-"; +} diff --git a/src/features/app-config/pages/BannerConfigPage.jsx b/src/features/app-config/pages/BannerConfigPage.jsx index 015b110..c6335b9 100644 --- a/src/features/app-config/pages/BannerConfigPage.jsx +++ b/src/features/app-config/pages/BannerConfigPage.jsx @@ -8,10 +8,14 @@ import { useMemo } from "react"; import { useBannerConfigPage } from "@/features/app-config/hooks/useBannerConfigPage.js"; import styles from "@/features/app-config/app-config.module.css"; import { Button } from "@/shared/ui/Button.jsx"; -import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx"; +import { + AdminFormDialog, + AdminFormFieldGrid, + AdminFormSection, + AdminFormSwitchField, +} from "@/shared/ui/AdminFormDialog.jsx"; import { AdminActionIconButton, - AdminFilterResetButton, AdminListBody, AdminListPage, AdminListToolbar, @@ -56,12 +60,6 @@ export function BannerConfigPage() { ) : null } - filters={ - - } /> @@ -75,7 +73,7 @@ export function BannerConfigPage() { - page.setForm({ coverUrl })} - /> - page.setForm({ bannerType: event.target.value })} - > - H5 - APP - - page.setForm({ param: event.target.value })} - /> - page.setForm({ status: event.target.value })} - > - 启用 - 关闭 - - page.setForm({ platform: event.target.value })} - > - 安卓 - iOS - - page.setForm({ sortOrder: event.target.value })} - /> - page.setForm({ regionId })} - /> - page.setForm({ countryCode })} - /> - page.setForm({ description: event.target.value })} - /> + + page.setForm({ coverUrl })} + /> + + + + page.setForm({ bannerType: event.target.value })} + > + H5 + APP + + page.setForm({ platform: event.target.value })} + > + 安卓 + iOS + + page.setForm({ param: event.target.value })} + /> + + + + + page.setForm({ regionId })} + /> + page.setForm({ countryCode })} + /> + page.setForm({ sortOrder: event.target.value })} + /> + page.setForm({ status: checked ? "active" : "disabled" })} + /> + + + + page.setForm({ description: event.target.value })} + /> + ); diff --git a/src/features/app-config/pages/H5ConfigPage.jsx b/src/features/app-config/pages/H5ConfigPage.jsx index 311ccb1..b9b3bbb 100644 --- a/src/features/app-config/pages/H5ConfigPage.jsx +++ b/src/features/app-config/pages/H5ConfigPage.jsx @@ -2,7 +2,7 @@ import EditOutlined from "@mui/icons-material/EditOutlined"; import LinkOutlined from "@mui/icons-material/LinkOutlined"; import TextField from "@mui/material/TextField"; import { formatMillis } from "@/shared/utils/time.js"; -import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx"; +import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { @@ -74,13 +74,15 @@ export function H5ConfigPage() { onClose={page.closeEdit} onSubmit={page.submitEdit} > - page.setForm({ url: event.target.value })} - /> + + page.setForm({ url: event.target.value })} + /> + ); diff --git a/src/features/app-config/permissions.js b/src/features/app-config/permissions.js index c630e1c..8af2af4 100644 --- a/src/features/app-config/permissions.js +++ b/src/features/app-config/permissions.js @@ -5,8 +5,12 @@ export function useAppConfigAbilities() { const { can } = useAuth(); return { + canCreateVersion: can(PERMISSIONS.appVersionCreate), + canDeleteVersion: can(PERMISSIONS.appVersionDelete), canUpdate: can(PERMISSIONS.appConfigUpdate), + canUpdateVersion: can(PERMISSIONS.appVersionUpdate), canUpload: can(PERMISSIONS.uploadCreate), - canView: can(PERMISSIONS.appConfigView) + canView: can(PERMISSIONS.appConfigView), + canViewVersion: can(PERMISSIONS.appVersionView) }; } diff --git a/src/features/app-config/routes.js b/src/features/app-config/routes.js index 9fbbc2a..0836c7c 100644 --- a/src/features/app-config/routes.js +++ b/src/features/app-config/routes.js @@ -16,5 +16,22 @@ export const appConfigRoutes = [ pageKey: "app-config-banners", path: "/app-config/banners", permission: PERMISSIONS.appConfigView + }, + { + label: "内购配置", + loader: () => + import("../payment/pages/RechargeProductConfigPage.jsx").then((module) => module.RechargeProductConfigPage), + menuCode: MENU_CODES.paymentRechargeProducts, + pageKey: "payment-recharge-products", + path: "/app-config/recharge-products", + permission: PERMISSIONS.paymentProductView + }, + { + label: "版本管理", + loader: () => import("./pages/AppVersionManagementPage.jsx").then((module) => module.AppVersionManagementPage), + menuCode: MENU_CODES.appConfigVersions, + pageKey: "app-config-versions", + path: "/app-config/versions", + permission: PERMISSIONS.appVersionView } ]; diff --git a/src/features/app-config/schema.ts b/src/features/app-config/schema.ts index 7d1fed2..5385c47 100644 --- a/src/features/app-config/schema.ts +++ b/src/features/app-config/schema.ts @@ -38,3 +38,15 @@ export const appBannerSchema = z.object({ }); export type AppBannerForm = z.infer; + +export const appVersionSchema = z.object({ + buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"), + description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""), + downloadUrl: z.string().trim().min(1, "请填写下载链接").max(2048, "下载链接不能超过 2048 个字符") + .refine((value) => !/\s/.test(value), "下载链接不能包含空白字符"), + forceUpdate: z.boolean(), + platform: z.enum(["android", "ios"]), + version: z.string().trim().min(1, "请填写版本号").max(40, "版本号不能超过 40 个字符") +}); + +export type AppVersionForm = z.infer; diff --git a/src/features/app-users/api.ts b/src/features/app-users/api.ts index f2eda7b..2f2c4ce 100644 --- a/src/features/app-users/api.ts +++ b/src/features/app-users/api.ts @@ -2,6 +2,7 @@ import { apiRequest } from "@/shared/api/request"; import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; import type { ApiPage, + AppUserBanRecordDto, AppUserLoginLogDto, AppUserDto, AppUserPasswordPayload, @@ -26,6 +27,14 @@ export function listAppUserLoginLogs(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.appListBannedUsers; + return apiRequest>(apiEndpointPath(API_OPERATIONS.appListBannedUsers), { + method: endpoint.method, + query, + }); +} + export function updateAppUser(userId: EntityId, payload: AppUserUpdatePayload): Promise { const endpoint = API_ENDPOINTS.appUpdateUser; return apiRequest(apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }), { diff --git a/src/features/app-users/pages/AppUserBanListPage.jsx b/src/features/app-users/pages/AppUserBanListPage.jsx new file mode 100644 index 0000000..9418512 --- /dev/null +++ b/src/features/app-users/pages/AppUserBanListPage.jsx @@ -0,0 +1,194 @@ +import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined"; +import { useCallback, useMemo, useState } from "react"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { createTextColumnFilter } from "@/shared/ui/tableFilters.js"; +import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; +import { formatMillis } from "@/shared/utils/time.js"; +import { listAppUserBans } from "@/features/app-users/api"; +import { appUserStatusLabels } from "@/features/app-users/constants.js"; +import styles from "@/features/app-users/app-users.module.css"; + +const pageSize = 20; +const emptyData = { items: [], page: 1, pageSize, total: 0 }; + +export function AppUserBanListPage() { + const [targetKeyword, setTargetKeyword] = useState(""); + const [operatorKeyword, setOperatorKeyword] = useState(""); + const [page, setPage] = useState(1); + const filters = useMemo( + () => ({ + operator_keyword: operatorKeyword, + target_keyword: targetKeyword, + }), + [operatorKeyword, targetKeyword], + ); + const { + data = emptyData, + error, + loading, + reload, + } = usePaginatedQuery({ + errorMessage: "加载封禁列表失败", + fetcher: listAppUserBans, + filters, + page, + pageSize, + queryKey: ["app-users", "bans", filters, page], + }); + + const changeTargetKeyword = useCallback((value) => { + setTargetKeyword(value); + setPage(1); + }, []); + + const changeOperatorKeyword = useCallback((value) => { + setOperatorKeyword(value); + setPage(1); + }, []); + + const columns = useMemo( + () => [ + { + key: "target", + label: "封禁用户", + width: "minmax(280px, 1.4fr)", + filter: createTextColumnFilter({ + placeholder: "搜索昵称、短 ID、长 ID", + value: targetKeyword, + onChange: changeTargetKeyword, + }), + render: (record) => , + }, + { + key: "operator", + label: "封禁人", + width: "minmax(280px, 1.4fr)", + filter: createTextColumnFilter({ + placeholder: "搜索账号、昵称、短 ID、长 ID", + value: operatorKeyword, + onChange: changeOperatorKeyword, + }), + render: (record) => , + }, + { + key: "status", + label: "状态", + width: "minmax(110px, 0.6fr)", + render: (record) => , + }, + { + key: "reason", + label: "原因", + width: "minmax(190px, 1fr)", + render: (record) => {record.reason || "-"}, + }, + { + key: "createdAtMs", + label: "封禁时间", + width: "190px", + render: (record) => {formatMillis(record.createdAtMs)}, + }, + ], + [changeOperatorKeyword, changeTargetKeyword, operatorKeyword, targetKeyword], + ); + + return ( +
+
+ +
+ 0 + ? { + page, + pageSize: data.pageSize || pageSize, + total: data.total, + onPageChange: setPage, + } + : undefined + } + rowKey={(record) => record.id} + /> +
+
+
+
+ ); +} + +function TargetIdentity({ target }) { + return ( + + ); +} + +function OperatorIdentity({ operator }) { + if (!operator) { + return -; + } + if (operator.type === "admin") { + return ( + + ); + } + if (operator.type === "app_user") { + return ( + + ); + } + return system; +} + +function UserIdentity({ avatar, name, primaryFallback, rows }) { + const title = name || primaryFallback || "-"; + const visibleRows = rows.filter(Boolean); + return ( +
+ + {avatar ? : } + +
+
+ {title} +
+ {visibleRows.length ? ( +
+ {visibleRows.map((value, index) => ( + + {value} + + ))} +
+ ) : null} +
+
+ ); +} + +function UserStatus({ status }) { + return ( + + + {appUserStatusLabels[status] || status || "-"} + + ); +} diff --git a/src/features/app-users/pages/AppUserListPage.jsx b/src/features/app-users/pages/AppUserListPage.jsx index c684d6c..21c19be 100644 --- a/src/features/app-users/pages/AppUserListPage.jsx +++ b/src/features/app-users/pages/AppUserListPage.jsx @@ -7,12 +7,10 @@ import Autocomplete from "@mui/material/Autocomplete"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; -import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx"; +import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; -import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx"; -import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { UploadField } from "@/shared/ui/UploadField.jsx"; import { formatMillis } from "@/shared/utils/time.js"; @@ -91,28 +89,24 @@ export function AppUserListPage() { return (
-
-
- -
-
-
0 + ? { + page: page.page, + pageSize: page.data.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } rowKey={(user) => user.userId} /> - {total > 0 ? ( - - ) : null}
@@ -121,6 +115,7 @@ export function AppUserListPage() { disabled={!page.abilities.canUpdate} loading={page.loadingAction === "edit"} open={page.activeAction === "edit"} + sectionTitle="用户资料" title="编辑用户" onClose={page.closeAction} onSubmit={page.submitEdit} @@ -156,6 +151,7 @@ export function AppUserListPage() { disabled={!page.abilities.canUpdate} loading={page.loadingAction === "country"} open={page.activeAction === "country"} + sectionTitle="国家信息" title="修改国家" onClose={page.closeAction} onSubmit={page.submitCountry} @@ -177,6 +173,7 @@ export function AppUserListPage() { disabled={!page.abilities.canPassword} loading={page.loadingAction === "password"} open={page.activeAction === "password"} + sectionTitle="密码信息" title="设置密码" onClose={page.closeAction} onSubmit={page.submitPassword} @@ -280,7 +277,7 @@ function UserStatus({ status }) { ); } -function ActionModal({ children, disabled, loading, onClose, onSubmit, open, title }) { +function ActionModal({ children, disabled, loading, onClose, onSubmit, open, sectionTitle = "基本信息", title }) { return ( - {children} + {children} ); } diff --git a/src/features/app-users/pages/AppUserLoginLogsPage.jsx b/src/features/app-users/pages/AppUserLoginLogsPage.jsx index 5ebb876..122395d 100644 --- a/src/features/app-users/pages/AppUserLoginLogsPage.jsx +++ b/src/features/app-users/pages/AppUserLoginLogsPage.jsx @@ -4,9 +4,6 @@ import { useSearchParams } from "react-router-dom"; import { toPageQuery } from "@/shared/api/query"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; -import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx"; -import { FilterChip } from "@/shared/ui/FilterChip.jsx"; -import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx"; @@ -68,11 +65,6 @@ export function AppUserLoginLogsPage() { setPage(1); }, []); - const clearUserFilter = useCallback(() => { - setSearchParams(new URLSearchParams(), { replace: true }); - setPage(1); - }, [setSearchParams]); - const showUserHistory = useCallback( (log) => { if (!log.userId) { @@ -89,13 +81,29 @@ export function AppUserLoginLogsPage() { [searchParams, setSearchParams], ); - const resetFilters = () => { + const resetIdentityFilter = useCallback(() => { setQuery(""); - setResult(""); - setRegionId(""); setSearchParams(new URLSearchParams(), { replace: true }); setPage(1); - }; + }, [setSearchParams]); + + const changeIdentityFilter = useCallback( + (value) => { + if (userFilter) { + setSearchParams(new URLSearchParams(), { replace: true }); + } + changeQuery(value); + }, + [changeQuery, setSearchParams, userFilter], + ); + + const selectedUserLabel = useMemo(() => { + if (!userFilter) { + return ""; + } + const matchedLog = (data.items || []).find((log) => String(log.userId || "") === String(userFilter)); + return matchedLog?.displayUserId || matchedLog?.username || userFilter; + }, [data.items, userFilter]); const columns = useMemo( () => [ @@ -104,9 +112,12 @@ export function AppUserLoginLogsPage() { label: "用户", width: "minmax(240px, 1.4fr)", filter: createTextColumnFilter({ + activeLabel: userFilter ? selectedUserLabel || userFilter : undefined, + activeValue: query || userFilter, + onReset: resetIdentityFilter, placeholder: "搜索昵称、短 ID、用户 ID、IP", value: query, - onChange: changeQuery, + onChange: changeIdentityFilter, }), render: (log) => , }, @@ -188,31 +199,24 @@ export function AppUserLoginLogsPage() { render: (log) => {formatMillis(log.createdAtMs)}, }, ], - [changeQuery, changeRegionId, changeResult, loadingRegions, query, regionFilterOptions, regionId, result], + [ + changeIdentityFilter, + changeRegionId, + changeResult, + loadingRegions, + query, + regionFilterOptions, + regionId, + resetIdentityFilter, + result, + selectedUserLabel, + userFilter, + ], ); - const selectedUserLabel = useMemo(() => { - if (!userFilter) { - return ""; - } - const matchedLog = (data.items || []).find((log) => String(log.userId || "") === String(userFilter)); - return matchedLog?.displayUserId || matchedLog?.username || userFilter; - }, [data.items, userFilter]); return (
-
-
- {userFilter ? ( - - ) : null} - -
-
-
loginLogRowProps(log, showUserHistory)} rowKey={(log) => log.id} - title="登录日志" - total={data.total} + pagination={ + data.total > 0 + ? { + page, + pageSize: data.pageSize || pageSize, + total: data.total, + onPageChange: setPage, + } + : undefined + } /> - {data.total > 0 ? ( - - ) : null}
diff --git a/src/features/app-users/routes.js b/src/features/app-users/routes.js index e099683..e750f33 100644 --- a/src/features/app-users/routes.js +++ b/src/features/app-users/routes.js @@ -17,4 +17,12 @@ export const appUserRoutes = [ path: "/app/users/login-logs", permission: PERMISSIONS.appUserView, }, + { + label: "封禁列表", + loader: () => import("./pages/AppUserBanListPage.jsx").then((module) => module.AppUserBanListPage), + menuCode: MENU_CODES.appUserBans, + pageKey: "app-user-bans", + path: "/app/users/bans", + permission: PERMISSIONS.appUserView, + }, ]; diff --git a/src/features/daily-tasks/pages/DailyTaskListPage.jsx b/src/features/daily-tasks/pages/DailyTaskListPage.jsx index fce92f3..342cddb 100644 --- a/src/features/daily-tasks/pages/DailyTaskListPage.jsx +++ b/src/features/daily-tasks/pages/DailyTaskListPage.jsx @@ -1,9 +1,14 @@ import Add from "@mui/icons-material/Add"; import EditOutlined from "@mui/icons-material/EditOutlined"; -import Switch from "@mui/material/Switch"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; -import { AdminFormDialog, AdminFormFieldGrid } from "@/shared/ui/AdminFormDialog.jsx"; +import { + AdminFormDialog, + AdminFormFieldGrid, + AdminFormSection, + AdminFormSwitchField, +} from "@/shared/ui/AdminFormDialog.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { @@ -15,7 +20,6 @@ import { AdminListToolbar, AdminRowActions, } from "@/shared/ui/AdminListLayout.jsx"; -import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { formatMillis } from "@/shared/utils/time.js"; import { @@ -175,15 +179,22 @@ export function DailyTaskListPage() { /> - task.taskId} /> - {total > 0 ? ( - - ) : null} + 0 + ? { + page: page.page, + pageSize: page.data.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(task) => task.taskId} + /> - - - page.setForm({ ...form, taskType: event.target.value })} - > - {taskTypeOptions.map(([value, label]) => ( - - {label} - - ))} - - page.setForm({ ...form, status: event.target.value })} - > - {taskStatusOptions - .filter(([value]) => value) - .map(([value, label]) => ( + + + page.setForm({ ...form, taskType: event.target.value })} + > + {taskTypeOptions.map(([value, label]) => ( {label} ))} - - page.setForm({ ...form, title: event.target.value })} - /> - page.setForm({ ...form, category: event.target.value })} - /> - page.setForm({ ...form, metricType: event.target.value })} - /> - page.setForm({ ...form, targetUnit: event.target.value })} - > - {taskUnitOptions.map(([value, label]) => ( - - {label} - - ))} - - page.setForm({ ...form, targetValue: event.target.value })} - /> - page.setForm({ ...form, rewardCoinAmount: event.target.value })} - /> - page.setForm({ ...form, sortOrder: event.target.value })} - /> - page.setForm({ ...form, effectiveFrom: event.target.value })} - /> - page.setForm({ ...form, effectiveTo: event.target.value })} - /> + + + page.setForm({ ...form, status: nextTaskStatusFromSwitch(form.status, checked, mode) }) + } + /> + page.setForm({ ...form, title: event.target.value })} + /> + page.setForm({ ...form, category: event.target.value })} + /> + + + + + page.setForm({ ...form, metricType: event.target.value })} + /> + page.setForm({ ...form, targetUnit: event.target.value })} + > + {taskUnitOptions.map(([value, label]) => ( + + {label} + + ))} + + page.setForm({ ...form, targetValue: event.target.value })} + /> + page.setForm({ ...form, rewardCoinAmount: event.target.value })} + /> + page.setForm({ ...form, sortOrder: event.target.value })} + /> + + + + + page.setForm({ ...form, effectiveFrom: event.target.value })} + /> + page.setForm({ ...form, effectiveTo: event.target.value })} + /> + + + page.setForm({ ...form, description: event.target.value })} /> - + ); } @@ -380,6 +398,23 @@ function taskStatusLabel(value) { return taskStatusLabels[value] || value || "-"; } +function taskSwitchUncheckedLabel(status, mode) { + if (status === "archived") { + return "归档"; + } + return status === "draft" || mode === "create" ? "草稿" : "暂停"; +} + +function nextTaskStatusFromSwitch(status, checked, mode) { + if (checked) { + return "active"; + } + if (status === "archived") { + return "archived"; + } + return status === "draft" || mode === "create" ? "draft" : "paused"; +} + function taskUnitLabel(value) { return taskUnitLabels[value] || value || ""; } diff --git a/src/features/dashboard/components/DashboardAlerts.jsx b/src/features/dashboard/components/DashboardAlerts.jsx deleted file mode 100644 index 6c14b4e..0000000 --- a/src/features/dashboard/components/DashboardAlerts.jsx +++ /dev/null @@ -1,16 +0,0 @@ -export function DashboardAlerts({ alerts = [] }) { - return ( -
- {alerts.map((alert) => ( -
- -
-
{alert.name}
-
{alert.desc}
-
-
{alert.time}
-
- ))} -
- ); -} diff --git a/src/features/dashboard/components/DashboardCharts.jsx b/src/features/dashboard/components/DashboardCharts.jsx index 2d72a0f..ee9fcb5 100644 --- a/src/features/dashboard/components/DashboardCharts.jsx +++ b/src/features/dashboard/components/DashboardCharts.jsx @@ -1,5 +1,3 @@ -import { Card } from "@/shared/ui/Card.jsx"; -import { BarChart } from "@/shared/charts/BarChart.jsx"; import { ChartCard } from "@/shared/charts/ChartCard.jsx"; export function DashboardCharts({ overview }) { @@ -7,12 +5,6 @@ export function DashboardCharts({ overview }) {
- -
-
通知趋势
-
- -
); } diff --git a/src/features/dashboard/components/DashboardStats.jsx b/src/features/dashboard/components/DashboardStats.jsx index 816c967..1299703 100644 --- a/src/features/dashboard/components/DashboardStats.jsx +++ b/src/features/dashboard/components/DashboardStats.jsx @@ -1,6 +1,5 @@ import DeveloperBoardOutlined from "@mui/icons-material/DeveloperBoardOutlined"; import DnsOutlined from "@mui/icons-material/DnsOutlined"; -import SecurityOutlined from "@mui/icons-material/SecurityOutlined"; import StorageOutlined from "@mui/icons-material/StorageOutlined"; import { KpiCard } from "@/shared/ui/KpiCard.jsx"; @@ -10,7 +9,6 @@ export function DashboardStats({ overview, stats }) { 启用 {stats.activeUsers}} /> - 锁定 {stats.lockedUsers} / 停用 {stats.disabledUsers}} />
); } diff --git a/src/features/dashboard/hooks/useDashboardPage.js b/src/features/dashboard/hooks/useDashboardPage.js index 2ce6aa7..8bd6fcf 100644 --- a/src/features/dashboard/hooks/useDashboardPage.js +++ b/src/features/dashboard/hooks/useDashboardPage.js @@ -23,7 +23,7 @@ export function useDashboardPage() { const stats = useMemo(() => { if (!overview) { - return { activeUsers: 0, disabledUsers: 0, lockedUsers: 0, logsToday: 0, menuTotal: 0, rolesTotal: 0, unreadNotifications: 0, usersTotal: 0 }; + return { activeUsers: 0, disabledUsers: 0, lockedUsers: 0, logsToday: 0, menuTotal: 0, rolesTotal: 0, usersTotal: 0 }; } return { activeUsers: overview.activeUsers, @@ -32,7 +32,6 @@ export function useDashboardPage() { logsToday: overview.logsToday, menuTotal: overview.menuTotal, rolesTotal: overview.rolesTotal, - unreadNotifications: overview.unreadNotifications, usersTotal: overview.usersTotal }; }, [overview]); diff --git a/src/features/dashboard/pages/OverviewPage.jsx b/src/features/dashboard/pages/OverviewPage.jsx index 0ace248..17668f7 100644 --- a/src/features/dashboard/pages/OverviewPage.jsx +++ b/src/features/dashboard/pages/OverviewPage.jsx @@ -1,5 +1,4 @@ import { DataState } from "@/shared/ui/DataState.jsx"; -import { DashboardAlerts } from "@/features/dashboard/components/DashboardAlerts.jsx"; import { DashboardCharts } from "@/features/dashboard/components/DashboardCharts.jsx"; import { DashboardStats } from "@/features/dashboard/components/DashboardStats.jsx"; import { DashboardToolbar } from "@/features/dashboard/components/DashboardToolbar.jsx"; @@ -20,8 +19,6 @@ export function OverviewPage() { - - ); diff --git a/src/features/games/api.ts b/src/features/games/api.ts new file mode 100644 index 0000000..4bcdaa9 --- /dev/null +++ b/src/features/games/api.ts @@ -0,0 +1,155 @@ +import { apiRequest } from "@/shared/api/request"; +import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import type { QueryParams } from "@/shared/api/request"; + +export interface GamePlatformDto { + appCode?: string; + platformCode: string; + platformName: string; + status: string; + apiBaseUrl: string; + sortOrder: number; + createdAtMs?: number; + updatedAtMs?: number; +} + +export interface GameCatalogDto { + appCode?: string; + gameId: string; + platformCode: string; + providerGameId: string; + gameName: string; + category: string; + iconUrl: string; + coverUrl: string; + launchMode: string; + orientation: string; + minCoin: number; + status: string; + sortOrder: number; + tags: string[]; + createdAtMs?: number; + updatedAtMs?: number; +} + +export interface GameCatalogPage { + items: GameCatalogDto[]; + nextCursor?: string; + pageSize?: number; + serverTimeMs?: number; +} + +export interface GamePlatformPage { + items: GamePlatformDto[]; + serverTimeMs?: number; +} + +export type GamePlatformPayload = Omit; +export type GameCatalogPayload = Omit; + +export interface GameCatalogQuery { + platformCode?: string; + status?: string; + pageSize?: number; + cursor?: string; +} + +export function listGamePlatforms(status = ""): Promise { + const endpoint = API_ENDPOINTS.listPlatforms; + return apiRequest(apiEndpointPath(API_OPERATIONS.listPlatforms), { + method: endpoint.method, + query: { status }, + }).then((page) => ({ + ...page, + items: (page.items || []).map(normalizePlatform), + })); +} + +export function upsertGamePlatform(payload: GamePlatformPayload, platformCode = ""): Promise { + const operation = platformCode ? API_OPERATIONS.updatePlatform : API_OPERATIONS.createPlatform; + const endpoint = API_ENDPOINTS[operation]; + return apiRequest( + apiEndpointPath(operation, platformCode ? { platform_code: platformCode } : {}), + { + body: payload, + method: endpoint.method, + }, + ).then(normalizePlatform); +} + +export function listGameCatalog(query: GameCatalogQuery = {}): Promise { + const endpoint = API_ENDPOINTS.listCatalog; + return apiRequest(apiEndpointPath(API_OPERATIONS.listCatalog), { + method: endpoint.method, + query: query as QueryParams, + }).then((page) => ({ + ...page, + items: (page.items || []).map(normalizeCatalog), + })); +} + +export function upsertGameCatalog(payload: GameCatalogPayload, gameId = ""): Promise { + const operation = gameId ? API_OPERATIONS.updateCatalog : API_OPERATIONS.createCatalog; + const endpoint = API_ENDPOINTS[operation]; + return apiRequest( + apiEndpointPath(operation, gameId ? { game_id: gameId } : {}), + { + body: payload, + method: endpoint.method, + }, + ).then(normalizeCatalog); +} + +export function setGameStatus(gameId: string, status: string): Promise { + const endpoint = API_ENDPOINTS.setGameStatus; + return apiRequest( + apiEndpointPath(API_OPERATIONS.setGameStatus, { game_id: gameId }), + { + body: { status }, + method: endpoint.method, + }, + ).then(normalizeCatalog); +} + +function normalizePlatform(platform: Partial): GamePlatformDto { + return { + appCode: stringValue(platform.appCode), + platformCode: stringValue(platform.platformCode), + platformName: stringValue(platform.platformName), + status: stringValue(platform.status || "active"), + apiBaseUrl: stringValue(platform.apiBaseUrl), + sortOrder: numberValue(platform.sortOrder), + createdAtMs: numberValue(platform.createdAtMs), + updatedAtMs: numberValue(platform.updatedAtMs), + }; +} + +function normalizeCatalog(game: Partial): GameCatalogDto { + return { + appCode: stringValue(game.appCode), + gameId: stringValue(game.gameId), + platformCode: stringValue(game.platformCode), + providerGameId: stringValue(game.providerGameId), + gameName: stringValue(game.gameName), + category: stringValue(game.category), + iconUrl: stringValue(game.iconUrl), + coverUrl: stringValue(game.coverUrl), + launchMode: stringValue(game.launchMode || "h5_popup"), + orientation: stringValue(game.orientation || "portrait"), + minCoin: numberValue(game.minCoin), + status: stringValue(game.status || "active"), + sortOrder: numberValue(game.sortOrder), + tags: Array.isArray(game.tags) ? game.tags.map(String).filter(Boolean) : [], + createdAtMs: numberValue(game.createdAtMs), + updatedAtMs: numberValue(game.updatedAtMs), + }; +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value); +} + +function numberValue(value: unknown) { + const number = Number(value || 0); + return Number.isFinite(number) ? number : 0; +} diff --git a/src/features/games/games.module.css b/src/features/games/games.module.css new file mode 100644 index 0000000..b6bce6c --- /dev/null +++ b/src/features/games/games.module.css @@ -0,0 +1,57 @@ +.identity { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.icon { + width: 44px; + height: 44px; + flex: 0 0 auto; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-card-strong); + object-fit: cover; +} + +.identityText, +.stack { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} + +.name { + overflow: hidden; + color: var(--text-primary); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.meta, +.tags { + overflow: hidden; + color: var(--text-tertiary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tags { + color: var(--text-secondary); +} + +.switchOnlyField { + display: inline-flex; + min-height: 56px; + align-items: center; + justify-content: flex-start; + width: 100%; +} + +.fullField { + grid-column: 1 / -1; +} diff --git a/src/features/games/hooks/useGamesPage.js b/src/features/games/hooks/useGamesPage.js new file mode 100644 index 0000000..c86192c --- /dev/null +++ b/src/features/games/hooks/useGamesPage.js @@ -0,0 +1,303 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + listGameCatalog, + listGamePlatforms, + setGameStatus, + upsertGameCatalog, + upsertGamePlatform, +} from "@/features/games/api"; +import { useGameAbilities } from "@/features/games/permissions.js"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; + +const defaultGameForm = { + gameId: "", + platformCode: "", + providerGameId: "", + gameName: "", + category: "casual", + iconUrl: "", + coverUrl: "", + launchMode: "h5_popup", + orientation: "portrait", + minCoin: 0, + status: "active", + sortOrder: 0, + tagsText: "", +}; + +const defaultPlatformForm = { + platformCode: "", + platformName: "", + status: "active", + apiBaseUrl: "", + sortOrder: 0, +}; + +const emptyCatalog = { items: [], pageSize: 50 }; +const emptyPlatforms = { items: [] }; +const defaultPageSize = 50; + +export function useGamesPage() { + const abilities = useGameAbilities(); + const { showToast } = useToast(); + const [platformCode, setPlatformCode] = useState(""); + const [status, setStatus] = useState(""); + const [page, setPage] = useState(1); + const [pageCursors, setPageCursors] = useState({ 1: "" }); + const [pageSize, setPageSize] = useState(defaultPageSize); + const [activeAction, setActiveAction] = useState(""); + const [editingGameId, setEditingGameId] = useState(""); + const [editingPlatformCode, setEditingPlatformCode] = useState(""); + const [gameForm, setGameForm] = useState(defaultGameForm); + const [platformForm, setPlatformForm] = useState(defaultPlatformForm); + const [loadingAction, setLoadingAction] = useState(""); + + const platformsQueryFn = useCallback(() => listGamePlatforms(), []); + const { + data: platformData = emptyPlatforms, + error: platformError, + loading: platformsLoading, + reload: reloadPlatforms, + } = useAdminQuery(platformsQueryFn, { + errorMessage: "加载游戏平台失败", + initialData: emptyPlatforms, + queryKey: ["games", "platforms"], + }); + + const catalogQueryFn = useCallback( + () => listGameCatalog({ cursor: pageCursors[page] || "", platformCode, status, pageSize }), + [page, pageCursors, pageSize, platformCode, status], + ); + const { + data = emptyCatalog, + error, + loading, + reload, + } = useAdminQuery(catalogQueryFn, { + errorMessage: "加载游戏列表失败", + initialData: emptyCatalog, + keepPreviousData: true, + queryKey: ["games", "catalog", platformCode, status, pageSize, page, pageCursors[page] || ""], + }); + + const platformOptions = useMemo( + () => (platformData.items || []).map((platform) => [platform.platformCode, platform.platformName]), + [platformData.items], + ); + + useEffect(() => { + if (!gameForm.platformCode && platformOptions.length > 0 && activeAction === "create-game") { + setGameForm((current) => ({ ...current, platformCode: platformOptions[0][0] })); + } + }, [activeAction, gameForm.platformCode, platformOptions]); + + const resetCatalogPage = () => { + setPage(1); + setPageCursors({ 1: "" }); + }; + + const changePlatformCode = (value) => { + setPlatformCode(value); + resetCatalogPage(); + }; + + const changeStatus = (value) => { + setStatus(value); + resetCatalogPage(); + }; + + const changePageSize = (value) => { + setPageSize(value); + resetCatalogPage(); + }; + + const changePage = (nextPage) => { + if (nextPage < 1 || nextPage === page) { + return; + } + if (nextPage === page + 1) { + if (!data.nextCursor) { + return; + } + setPageCursors((current) => ({ ...current, [nextPage]: data.nextCursor })); + } + setPage(nextPage); + }; + + const resetFilters = () => { + setPlatformCode(""); + setStatus(""); + setPageSize(defaultPageSize); + resetCatalogPage(); + }; + + const openCreateGame = () => { + setEditingGameId(""); + setGameForm({ ...defaultGameForm, platformCode: platformOptions[0]?.[0] || "" }); + setActiveAction("create-game"); + }; + + const openEditGame = (game) => { + setEditingGameId(game.gameId); + setGameForm(gameToForm(game)); + setActiveAction("edit-game"); + }; + + const openCreatePlatform = () => { + setEditingPlatformCode(""); + setPlatformForm(defaultPlatformForm); + setActiveAction("create-platform"); + }; + + const openEditPlatform = (platform) => { + setEditingPlatformCode(platform.platformCode); + setPlatformForm(platformToForm(platform)); + setActiveAction("edit-platform"); + }; + + const closeAction = () => { + setActiveAction(""); + setEditingGameId(""); + setEditingPlatformCode(""); + }; + + const submitGame = async (event) => { + event.preventDefault(); + const payload = gamePayload(gameForm); + setLoadingAction(activeAction); + try { + await upsertGameCatalog(payload, editingGameId); + await reload(); + showToast(editingGameId ? "游戏已更新" : "游戏已创建", "success"); + closeAction(); + } catch (err) { + showToast(err.message || "保存游戏失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + const submitPlatform = async (event) => { + event.preventDefault(); + const payload = platformPayload(platformForm); + setLoadingAction(activeAction); + try { + await upsertGamePlatform(payload, editingPlatformCode); + await reloadPlatforms(); + await reload(); + showToast(editingPlatformCode ? "平台已更新" : "平台已创建", "success"); + closeAction(); + } catch (err) { + showToast(err.message || "保存平台失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + const changeGameStatus = async (game, checked) => { + const nextStatus = checked ? "active" : "maintenance"; + setLoadingAction(`status-${game.gameId}`); + try { + await setGameStatus(game.gameId, nextStatus); + await reload(); + showToast("游戏状态已更新", "success"); + } catch (err) { + showToast(err.message || "状态更新失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + return { + abilities, + activeAction, + changeGameStatus, + closeAction, + data, + error: error || platformError, + gameForm, + loading: loading || platformsLoading, + loadingAction, + page, + openCreateGame, + openCreatePlatform, + openEditGame, + openEditPlatform, + pageSize, + platformCode, + platformData, + platformForm, + platformOptions, + reload, + resetFilters, + changePage, + changePageSize, + setGameForm, + setPlatformCode: changePlatformCode, + setPlatformForm, + setStatus: changeStatus, + status, + submitGame, + submitPlatform, + }; +} + +function gameToForm(game) { + return { + gameId: game.gameId || "", + platformCode: game.platformCode || "", + providerGameId: game.providerGameId || "", + gameName: game.gameName || "", + category: game.category || "casual", + iconUrl: game.iconUrl || "", + coverUrl: game.coverUrl || "", + launchMode: game.launchMode || "h5_popup", + orientation: game.orientation || "portrait", + minCoin: Number(game.minCoin || 0), + status: game.status || "active", + sortOrder: Number(game.sortOrder || 0), + tagsText: (game.tags || []).join(", "), + }; +} + +function platformToForm(platform) { + return { + platformCode: platform.platformCode || "", + platformName: platform.platformName || "", + status: platform.status || "active", + apiBaseUrl: platform.apiBaseUrl || "", + sortOrder: Number(platform.sortOrder || 0), + }; +} + +function gamePayload(form) { + return { + gameId: form.gameId.trim(), + platformCode: form.platformCode.trim(), + providerGameId: form.providerGameId.trim(), + gameName: form.gameName.trim(), + category: form.category.trim(), + iconUrl: form.iconUrl.trim(), + coverUrl: form.coverUrl.trim(), + launchMode: form.launchMode.trim() || "h5_popup", + orientation: form.orientation.trim() || "portrait", + minCoin: Number(form.minCoin || 0), + status: form.status.trim() || "active", + sortOrder: Number(form.sortOrder || 0), + tags: form.tagsText + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean), + }; +} + +function platformPayload(form) { + return { + platformCode: form.platformCode.trim(), + platformName: form.platformName.trim(), + status: form.status.trim() || "active", + apiBaseUrl: form.apiBaseUrl.trim(), + sortOrder: Number(form.sortOrder || 0), + }; +} diff --git a/src/features/games/pages/GameListPage.jsx b/src/features/games/pages/GameListPage.jsx new file mode 100644 index 0000000..8159c52 --- /dev/null +++ b/src/features/games/pages/GameListPage.jsx @@ -0,0 +1,440 @@ +import Add from "@mui/icons-material/Add"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { + AdminActionIconButton, + AdminListBody, + AdminListPage, + AdminListToolbar, + AdminRowActions, +} from "@/shared/ui/AdminListLayout.jsx"; +import { createOptionsColumnFilter } from "@/shared/ui/tableFilters.js"; +import { UploadField } from "@/shared/ui/UploadField.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; +import { useGamesPage } from "@/features/games/hooks/useGamesPage.js"; +import styles from "@/features/games/games.module.css"; + +const statusOptions = [ + ["", "全部状态"], + ["active", "Active"], + ["maintenance", "Maintenance"], + ["disabled", "Disabled"], +]; + +const orientationOptions = [ + ["portrait", "Portrait"], + ["landscape", "Landscape"], +]; +const launchModeOptions = [["h5_popup", "H5 弹窗"]]; + +const baseColumns = [ + { + key: "game", + label: "游戏", + width: "minmax(290px, 1.5fr)", + render: (game) => , + }, + { + key: "platform", + label: "平台", + width: "minmax(160px, 0.75fr)", + render: (game) => ( +
+ {game.platformCode} + {game.providerGameId} +
+ ), + }, + { + key: "launch", + label: "启动", + width: "minmax(150px, 0.75fr)", + render: (game) => ( +
+ {game.launchMode} + {game.orientation} +
+ ), + }, + { + key: "coin", + label: "最低金币", + width: "minmax(116px, 0.55fr)", + render: (game) => formatNumber(game.minCoin), + }, + { + key: "status", + label: "状态", + width: "minmax(120px, 0.6fr)", + }, + { + key: "updatedAt", + label: "更新时间", + width: "minmax(170px, 0.9fr)", + render: (game) => formatMillis(game.updatedAtMs || game.createdAtMs), + }, + { + key: "actions", + label: "操作", + width: "minmax(76px, 0.4fr)", + }, +]; + +export function GameListPage() { + const page = useGamesPage(); + const items = page.data.items || []; + const platformFilterOptions = [["", "全部平台"], ...page.platformOptions]; + const columns = baseColumns.map((column) => { + if (column.key === "platform") { + return { + ...column, + filter: createOptionsColumnFilter({ + options: platformFilterOptions, + placeholder: "搜索平台", + value: page.platformCode, + onChange: page.setPlatformCode, + }), + }; + } + if (column.key === "status") { + return { + ...column, + filter: createOptionsColumnFilter({ + options: statusOptions, + placeholder: "搜索状态", + value: page.status, + onChange: page.setStatus, + }), + render: (game) => , + }; + } + if (column.key === "actions") { + return { + ...column, + render: (game) => , + }; + } + return column; + }); + + return ( + + + {page.abilities.canUpdate ? ( + + + + ) : null} + {page.abilities.canCreate ? ( + + + + ) : null} + + } + /> + + + game.gameId} + /> + + + + + + ); +} + +function GameIdentity({ game }) { + return ( +
+ {game.iconUrl ? ( + + ) : ( + + )} +
+ {game.gameName || game.gameId} + {game.gameId} + {(game.tags || []).slice(0, 3).join(" / ")} +
+
+ ); +} + +function GameStatusSwitch({ page, game }) { + const disabled = + !page.abilities.canStatus || game.status === "disabled" || page.loadingAction === `status-${game.gameId}`; + return ( + page.changeGameStatus(game, event.target.checked)} + /> + ); +} + +function GameRowActions({ page, game }) { + return ( + + page.openEditGame(game)} + > + + + + ); +} + +function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) { + const disabled = mode === "edit" ? !page.abilities.canUpdate : !page.abilities.canCreate; + return ( + + + + page.setGameForm({ ...form, gameId: event.target.value })} + /> + 0} + value={form.platformCode} + onChange={(event) => page.setGameForm({ ...form, platformCode: event.target.value })} + > + {page.platformOptions.map(([value, label]) => ( + + {label || value} + + ))} + + page.setGameForm({ ...form, providerGameId: event.target.value })} + /> + page.setGameForm({ ...form, gameName: event.target.value })} + /> + page.setGameForm({ ...form, category: event.target.value })} + /> + + + + + page.setGameForm({ ...form, launchMode: event.target.value })} + > + {launchModeOptions.map(([value, label]) => ( + + {label} + + ))} + + page.setGameForm({ ...form, orientation: event.target.value })} + > + {orientationOptions.map(([value, label]) => ( + + {label} + + ))} + + page.setGameForm({ ...form, minCoin: event.target.value })} + /> + page.setGameForm({ ...form, sortOrder: event.target.value })} + /> + + page.setGameForm({ + ...form, + status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance", + }) + } + /> + + + + + page.setGameForm({ ...form, iconUrl })} + /> + page.setGameForm({ ...form, coverUrl })} + /> + page.setGameForm({ ...form, tagsText: event.target.value })} + /> + + + + ); +} + +function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) { + return ( + + + + page.setPlatformForm({ ...form, platformCode: event.target.value })} + /> + page.setPlatformForm({ ...form, platformName: event.target.value })} + /> + + page.setPlatformForm({ + ...form, + status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance", + }) + } + /> + page.setPlatformForm({ ...form, sortOrder: event.target.value })} + /> + + + + page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })} + /> + + + ); +} + +function SwitchOnlyField({ checked, checkedLabel, disabled, label, onChange, uncheckedLabel }) { + return ( + + onChange(event.target.checked, event)} + /> + + ); +} + +function formatNumber(value) { + return Number(value || 0).toLocaleString(); +} diff --git a/src/features/games/permissions.js b/src/features/games/permissions.js new file mode 100644 index 0000000..f788c97 --- /dev/null +++ b/src/features/games/permissions.js @@ -0,0 +1,14 @@ +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { PERMISSIONS } from "@/app/permissions"; + +export function useGameAbilities() { + const { can } = useAuth(); + + return { + canCreate: can(PERMISSIONS.gameCreate), + canStatus: can(PERMISSIONS.gameStatus), + canUpdate: can(PERMISSIONS.gameUpdate), + canUpload: can(PERMISSIONS.uploadCreate), + canView: can(PERMISSIONS.gameView), + }; +} diff --git a/src/features/games/routes.js b/src/features/games/routes.js new file mode 100644 index 0000000..2ef5d87 --- /dev/null +++ b/src/features/games/routes.js @@ -0,0 +1,12 @@ +import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; + +export const gameRoutes = [ + { + label: "游戏列表", + loader: () => import("./pages/GameListPage.jsx").then((module) => module.GameListPage), + menuCode: MENU_CODES.gameList, + pageKey: "game-list", + path: "/games", + permission: PERMISSIONS.gameView, + }, +]; diff --git a/src/features/host-org/components/HostOrgActionModal.jsx b/src/features/host-org/components/HostOrgActionModal.jsx index 4f89886..b6ea470 100644 --- a/src/features/host-org/components/HostOrgActionModal.jsx +++ b/src/features/host-org/components/HostOrgActionModal.jsx @@ -1,4 +1,4 @@ -import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx"; +import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx"; export function HostOrgActionModal({ children, @@ -7,6 +7,8 @@ export function HostOrgActionModal({ onClose, onSubmit, open, + sectioned = true, + sectionTitle = "表单信息", size = "compact", submitLabel = "提交", submitVariant = "primary", @@ -24,7 +26,7 @@ export function HostOrgActionModal({ onClose={onClose} onSubmit={onSubmit} > - {children} + {sectioned ? {children} : children} ); } diff --git a/src/features/host-org/components/HostOrgFilters.jsx b/src/features/host-org/components/HostOrgFilters.jsx deleted file mode 100644 index 21720a4..0000000 --- a/src/features/host-org/components/HostOrgFilters.jsx +++ /dev/null @@ -1,20 +0,0 @@ -import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx"; -import styles from "@/features/host-org/host-org.module.css"; - -export function HostOrgFilters({ - extraFilters = [], - onReset, - query, - regionId, - status, -}) { - const hasActiveFilters = [query, regionId, status, ...extraFilters.map((filter) => filter.value)].some( - (value) => String(value || "").trim() !== "", - ); - - return ( -
- {onReset ? : null} -
- ); -} diff --git a/src/features/host-org/components/HostOrgToolbar.jsx b/src/features/host-org/components/HostOrgToolbar.jsx index 946238e..a9d5846 100644 --- a/src/features/host-org/components/HostOrgToolbar.jsx +++ b/src/features/host-org/components/HostOrgToolbar.jsx @@ -1,35 +1,32 @@ import Tooltip from "@mui/material/Tooltip"; -import { HostOrgFilters } from "@/features/host-org/components/HostOrgFilters.jsx"; import styles from "@/features/host-org/host-org.module.css"; import { IconButton } from "@/shared/ui/IconButton.jsx"; -export function HostOrgToolbar({ actions = [], filters }) { +export function HostOrgToolbar({ actions = [] }) { + if (!actions.length) { + return null; + } + return (
-
- +
+ {actions.map((action) => ( + + + + {action.icon} + + + + ))}
- - {actions.length ? ( -
- {actions.map((action) => ( - - - - {action.icon} - - - - ))} -
- ) : null}
); } diff --git a/src/features/host-org/hooks/useHostAgenciesPage.js b/src/features/host-org/hooks/useHostAgenciesPage.js index ccbb057..3bfc9c8 100644 --- a/src/features/host-org/hooks/useHostAgenciesPage.js +++ b/src/features/host-org/hooks/useHostAgenciesPage.js @@ -116,6 +116,19 @@ export function useHostAgenciesPage() { }); }; + const closeAgencyFromList = async (agency) => { + if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") { + return; + } + await runAction(`agency-status-${agency.agencyId}`, "Agency 已关闭", async () => { + await closeAgency(agency.agencyId, { + commandId: makeCommandId("agency-close"), + reason: "close agency from list switch", + }); + await reload(); + }); + }; + const submitJoinEnabled = async (event) => { event.preventDefault(); await runAction("agency-join", "入会开关已更新", async () => { @@ -149,6 +162,7 @@ export function useHostAgenciesPage() { changeQuery, changeRegionId, changeStatus, + closeAgencyFromList, closeForm, data, error, diff --git a/src/features/host-org/hooks/useHostBdsPage.js b/src/features/host-org/hooks/useHostBdsPage.js index 0194759..54fca9e 100644 --- a/src/features/host-org/hooks/useHostBdsPage.js +++ b/src/features/host-org/hooks/useHostBdsPage.js @@ -15,17 +15,10 @@ import { import { listAppUsers } from "@/features/app-users/api"; import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { useBDAbilities } from "@/features/host-org/permissions.js"; -import { bdStatusSchema, createBDSchema, createBDLeaderSchema } from "@/features/host-org/schema"; +import { createBDSchema, createBDLeaderSchema } from "@/features/host-org/schema"; const emptyBDLeaderForm = () => ({ commandId: makeCommandId("bd-leader"), reason: "", regionId: "", targetUserId: "" }); const emptyBDForm = () => ({ commandId: makeCommandId("bd"), parentLeaderUserId: "", reason: "", targetUserId: "" }); -const emptyBDStatusForm = (targetType = "bd") => ({ - commandId: makeCommandId("bd-status"), - reason: "", - status: "active", - targetType, - targetUserId: "", -}); const pageSize = 10; const emptyData = { items: [], page: 1, pageSize, total: 0 }; const emptyLookup = () => ({ error: "", loading: false, user: null }); @@ -42,7 +35,6 @@ export function useHostBdsPage({ profileType = "bd" } = {}) { const [page, setPage] = useState(1); const [bdLeaderForm, setBDLeaderForm] = useState(emptyBDLeaderForm); const [bdForm, setBDForm] = useState(emptyBDForm); - const [bdStatusForm, setBDStatusForm] = useState(() => emptyBDStatusForm(profileType)); const [bdUserLookup, setBDUserLookup] = useState(emptyLookup); const [leaderBDs, setLeaderBDs] = useState(emptyData); const [leaderBDsError, setLeaderBDsError] = useState(""); @@ -194,22 +186,6 @@ export function useHostBdsPage({ profileType = "bd" } = {}) { }); }; - const submitBDStatus = async (event) => { - event.preventDefault(); - await runAction("bd-status", profileType === "leader" ? "BD Leader 状态已更新" : "BD 状态已更新", async () => { - const payload = parseForm(bdStatusSchema, { ...bdStatusForm, targetType: profileType }); - const { targetType, targetUserId, ...body } = payload; - const data = - targetType === "leader" - ? await setBDLeaderStatus(targetUserId, body) - : await setBDStatus(targetUserId, body); - setBDStatusForm(emptyBDStatusForm(profileType)); - setActiveAction(""); - await reload(); - return data; - }); - }; - const toggleBDLeader = async (leader, enabled) => { const status = enabled ? "active" : "disabled"; await runAction( @@ -227,6 +203,19 @@ export function useHostBdsPage({ profileType = "bd" } = {}) { ); }; + const toggleBD = async (bd, enabled) => { + const status = enabled ? "active" : "disabled"; + await runAction(`bd-status-${bd.userId}`, enabled ? "BD 已启用" : "BD 已停用", async () => { + const data = await setBDStatus(bd.userId, { + commandId: makeCommandId("bd-status"), + reason: "", + status, + }); + await reload(); + return data; + }); + }; + const removeBDLeader = async (leader) => { const ok = await confirm({ confirmText: "删除", @@ -284,7 +273,6 @@ export function useHostBdsPage({ profileType = "bd" } = {}) { activeAction, bdForm, bdLeaderForm, - bdStatusForm, bdUserLookup, changeParentLeaderUserId, changeQuery, @@ -312,18 +300,16 @@ export function useHostBdsPage({ profileType = "bd" } = {}) { openAddLeaderBD, openBDForm: () => setActiveAction("bd"), openBDLeaderForm: () => setActiveAction("bd-leader"), - openBDStatusForm: () => setActiveAction("bd-status"), openLeaderBDs, selectedLeader, setBDForm, setBDLeaderForm, - setBDStatusForm, setPage, status, submitBD, submitBDLeader, submitLeaderBD, - submitBDStatus, + toggleBD, toggleBDLeader, }; } diff --git a/src/features/host-org/hooks/useHostManagersPage.js b/src/features/host-org/hooks/useHostManagersPage.js new file mode 100644 index 0000000..3c09c0f --- /dev/null +++ b/src/features/host-org/hooks/useHostManagersPage.js @@ -0,0 +1,123 @@ +import { useMemo, useState } from "react"; +import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; +import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { closeAgency, listAgencies } from "@/features/host-org/api"; +import { useAgencyAbilities } from "@/features/host-org/permissions.js"; + +const pageSize = 10; +const emptyData = { items: [], page: 1, pageSize, total: 0 }; + +export function useHostManagersPage() { + const abilities = useAgencyAbilities(); + const { showToast } = useToast(); + const { loadingRegions, regionOptions } = useRegionOptions(); + const [query, setQuery] = useState(""); + const [status, setStatus] = useState(""); + const [regionId, setRegionId] = useState(""); + const [parentBdUserId, setParentBdUserId] = useState(""); + const [page, setPage] = useState(1); + const [loadingAction, setLoadingAction] = useState(""); + const filters = useMemo( + () => ({ + keyword: query, + parent_bd_user_id: parentBdUserId, + region_id: regionId, + status, + }), + [parentBdUserId, query, regionId, status], + ); + const { + data = emptyData, + error, + loading, + reload, + } = usePaginatedQuery({ + errorMessage: "加载经理列表失败", + fetcher: listAgencies, + filters, + page, + pageSize, + queryKey: ["host-org", "managers", filters, page], + }); + + const changeQuery = (value) => { + setQuery(value); + setPage(1); + }; + + const changeStatus = (value) => { + setStatus(value); + setPage(1); + }; + + const changeRegionId = (value) => { + setRegionId(value); + setPage(1); + }; + + const changeParentBdUserId = (value) => { + setParentBdUserId(value); + setPage(1); + }; + + const resetFilters = () => { + setQuery(""); + setStatus(""); + setRegionId(""); + setParentBdUserId(""); + setPage(1); + }; + + const closeManagerAgency = async (agency) => { + if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") { + return; + } + await runAction(`manager-status-${agency.agencyId}`, "经理已停用", async () => { + await closeAgency(agency.agencyId, { + commandId: makeCommandId("manager-close"), + reason: "close manager agency from list switch", + }); + await reload(); + }); + }; + + const runAction = async (action, successMessage, submitter) => { + setLoadingAction(action); + try { + await submitter(); + showToast(successMessage, "success"); + } catch (err) { + showToast(err.message || "操作失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + return { + abilities, + changeParentBdUserId, + changeQuery, + changeRegionId, + changeStatus, + closeManagerAgency, + data, + error, + loading, + loadingAction, + loadingRegions, + page, + parentBdUserId, + query, + regionId, + regionOptions, + reload, + resetFilters, + setPage, + status, + }; +} + +function makeCommandId(prefix) { + return `${prefix}-${Date.now()}`; +} diff --git a/src/features/host-org/host-org.module.css b/src/features/host-org/host-org.module.css index 5eb67f4..6c888aa 100644 --- a/src/features/host-org/host-org.module.css +++ b/src/features/host-org/host-org.module.css @@ -63,6 +63,7 @@ flex: 0 0 auto; align-items: center; gap: var(--space-2); + margin-left: auto; } .filters { diff --git a/src/features/host-org/pages/HostAgenciesPage.jsx b/src/features/host-org/pages/HostAgenciesPage.jsx index 4ed01ef..d8d28a8 100644 --- a/src/features/host-org/pages/HostAgenciesPage.jsx +++ b/src/features/host-org/pages/HostAgenciesPage.jsx @@ -1,16 +1,13 @@ import Add from "@mui/icons-material/Add"; import BlockOutlined from "@mui/icons-material/BlockOutlined"; import ToggleOnOutlined from "@mui/icons-material/ToggleOnOutlined"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import Switch from "@mui/material/Switch"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import TextField from "@mui/material/TextField"; import { formatMillis } from "@/shared/utils/time.js"; import { DataState } from "@/shared/ui/DataState.jsx"; -import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { agencyStatusFilters } from "@/features/host-org/constants.js"; import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx"; import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx"; -import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx"; import { useHostAgenciesPage } from "@/features/host-org/hooks/useHostAgenciesPage.js"; @@ -45,7 +42,7 @@ const agencyColumns = [ { key: "status", label: "状态", - render: (item) => , + render: (item, _index, context) => , width: "minmax(90px, 0.7fr)", }, { @@ -129,46 +126,27 @@ export function HostAgenciesPage() {
0 + ? { + page: page.page, + pageSize: page.data.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } rowKey={(item) => item.agencyId} /> - {total > 0 ? ( - - ) : null}
@@ -177,6 +155,7 @@ export function HostAgenciesPage() { disabled={createDisabled} loading={page.loadingAction === "agency"} open={page.activeAction === "agency"} + sectionTitle="Agency 信息" onClose={page.closeAction} onSubmit={page.submitAgency} title="创建 Agency" @@ -211,17 +190,15 @@ export function HostAgenciesPage() { value={page.agencyForm.maxHosts} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, maxHosts: event.target.value })} /> - - page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked }) - } - /> + + page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked }) } - label={page.agencyForm.joinEnabled ? "允许入会" : "禁止入会"} /> - - page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked }) - } - /> + + page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked }) } - label={page.joinEnabledForm.joinEnabled ? "允许入会" : "禁止入会"} /> ); } + +function AgencyStatusSwitch({ item, page }) { + const active = item.status === "active"; + return ( + { + if (!event.target.checked) { + page.closeAgencyFromList(item); + } + }} + /> + ); +} diff --git a/src/features/host-org/pages/HostBdLeadersPage.jsx b/src/features/host-org/pages/HostBdLeadersPage.jsx index 9b3df5d..d29e6f5 100644 --- a/src/features/host-org/pages/HostBdLeadersPage.jsx +++ b/src/features/host-org/pages/HostBdLeadersPage.jsx @@ -1,14 +1,12 @@ import Add from "@mui/icons-material/Add"; import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import PersonAddAlt1Outlined from "@mui/icons-material/PersonAddAlt1Outlined"; -import Switch from "@mui/material/Switch"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import TextField from "@mui/material/TextField"; import { formatMillis } from "@/shared/utils/time.js"; import { DataState } from "@/shared/ui/DataState.jsx"; -import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; import { SideDrawer } from "@/shared/ui/SideDrawer.jsx"; -import { bdStatusFilters } from "@/features/host-org/constants.js"; import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx"; import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; @@ -88,19 +86,6 @@ export function HostBdLeadersPage() {
@@ -110,16 +95,18 @@ export function HostBdLeadersPage() { context={{ page, regionOptions: page.regionOptions }} items={items} minWidth="1120px" + pagination={ + total > 0 + ? { + page: page.page, + pageSize: page.data.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } rowKey={(item) => `bd-leader-${item.userId}`} /> - {total > 0 ? ( - - ) : null}
@@ -128,6 +115,7 @@ export function HostBdLeadersPage() { disabled={createDisabled} loading={page.loadingAction === "bd-leader"} open={page.activeAction === "bd-leader"} + sectionTitle="Leader 信息" onClose={page.closeAction} onSubmit={page.submitBDLeader} title="创建 BD Leader" @@ -163,6 +151,7 @@ export function HostBdLeadersPage() { disabled={createDisabled} loading={page.loadingAction === "leader-add-bd"} open={page.activeAction === "leader-add-bd"} + sectionTitle="下级 BD 信息" onClose={page.closeAction} onSubmit={page.submitLeaderBD} title="增加下级 BD" @@ -258,7 +247,7 @@ function CreatorIdentity({ item }) { function BDLeaderStatusSwitch({ item, page }) { return ( - , + render: (item, _index, context) => , width: "minmax(90px, 0.7fr)", }, { @@ -64,13 +61,9 @@ const bdColumns = [ export function HostBdsPage() { const page = useHostBdsPage({ profileType: "bd" }); const createDisabled = !page.abilities.canCreate; - const updateDisabled = !page.abilities.canUpdate; const items = page.data.items || []; const total = page.data.total || 0; const toolbarActions = [ - page.abilities.canUpdate - ? { icon: , label: "BD 状态", onClick: page.openBDStatusForm } - : null, page.abilities.canCreate ? { icon: , label: "创建 BD", onClick: page.openBDForm, variant: "primary" } : null, @@ -115,46 +108,27 @@ export function HostBdsPage() {
0 + ? { + page: page.page, + pageSize: page.data.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } rowKey={(item) => `bd-${item.userId}`} /> - {total > 0 ? ( - - ) : null}
@@ -163,6 +137,7 @@ export function HostBdsPage() { disabled={createDisabled} loading={page.loadingAction === "bd"} open={page.activeAction === "bd"} + sectionTitle="BD 信息" onClose={page.closeAction} onSubmit={page.submitBD} title="创建 BD" @@ -192,45 +167,6 @@ export function HostBdsPage() { onChange={(event) => page.setBDForm({ ...page.bdForm, reason: event.target.value })} /> - - - - page.setBDStatusForm({ ...page.bdStatusForm, targetUserId: event.target.value }) - } - /> - page.setBDStatusForm({ ...page.bdStatusForm, status: event.target.value })} - > - 启用 - 停用 - - page.setBDStatusForm({ ...page.bdStatusForm, reason: event.target.value })} - /> -
); } @@ -260,6 +196,17 @@ function ParentLeader({ item }) { ); } +function BDStatusSwitch({ item, page }) { + return ( + page.toggleBD(item, event.target.checked)} + /> + ); +} + function Creator({ item }) { if (!item.createdByUserId && !item.createdByDisplayUserId && !item.createdByUsername) { return "-"; diff --git a/src/features/host-org/pages/HostCoinSellersPage.jsx b/src/features/host-org/pages/HostCoinSellersPage.jsx index d667922..2c378cd 100644 --- a/src/features/host-org/pages/HostCoinSellersPage.jsx +++ b/src/features/host-org/pages/HostCoinSellersPage.jsx @@ -1,7 +1,7 @@ import Add from "@mui/icons-material/Add"; import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined"; import MenuItem from "@mui/material/MenuItem"; -import Switch from "@mui/material/Switch"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import TextField from "@mui/material/TextField"; import { AdminFormAmountField, @@ -11,7 +11,6 @@ import { } from "@/shared/ui/AdminFormDialog.jsx"; import { formatMillis } from "@/shared/utils/time.js"; import { DataState } from "@/shared/ui/DataState.jsx"; -import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; import { coinSellerStatusFilters } from "@/features/host-org/constants.js"; import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx"; @@ -131,19 +130,6 @@ export function HostCoinSellersPage() {
@@ -153,17 +139,19 @@ export function HostCoinSellersPage() { context={{ page, regionOptions: page.regionOptions }} items={items} minWidth="1040px" + pagination={ + total > 0 + ? { + page: page.page, + pageSize: page.data.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } regionFilter={regionFilter} rowKey={(item) => item.userId} /> - {total > 0 ? ( - - ) : null}
@@ -172,6 +160,7 @@ export function HostCoinSellersPage() { disabled={createDisabled} loading={page.loadingAction === "coin-seller-create"} open={page.activeAction === "create"} + sectionTitle="币商信息" onClose={page.closeAction} onSubmit={page.submitCreateSeller} title="添加币商" @@ -196,6 +185,7 @@ export function HostCoinSellersPage() { disabled={updateDisabled} loading={String(page.loadingAction).startsWith("coin-seller-edit")} open={page.activeAction === "edit"} + sectionTitle="联系方式" onClose={page.closeAction} onSubmit={page.submitEditSeller} title="编辑联系方式" @@ -213,6 +203,7 @@ export function HostCoinSellersPage() { disabled={!page.abilities.canStockCredit || page.selectedSeller?.status !== "active"} loading={String(page.loadingAction).startsWith("coin-seller-stock")} open={page.activeAction === "stock"} + sectioned={false} size="standard" submitLabel="确认入账" onClose={page.closeAction} @@ -308,7 +299,7 @@ function SellerIdentity({ item }) { function SellerStatusSwitch({ item, page }) { return ( - - } actions={ page.abilities.canCreate ? ( @@ -113,6 +108,7 @@ export function HostCountriesPage() { disabled={page.activeAction === "edit" ? updateDisabled : createDisabled} loading={page.loadingAction === "country-create" || page.loadingAction === "country-edit"} open={page.activeAction === "create" || page.activeAction === "edit"} + sectionTitle="国家信息" onClose={page.closeAction} onSubmit={page.submitCountry} title={page.activeAction === "edit" ? "编辑国家" : "创建国家"} @@ -174,17 +170,15 @@ export function HostCountriesPage() { onChange={(event) => page.setCountryForm({ ...page.countryForm, sortOrder: event.target.value })} /> {page.activeAction === "create" ? ( - - page.setCountryForm({ ...page.countryForm, enabled: event.target.checked }) - } - /> + + page.setCountryForm({ ...page.countryForm, enabled: event.target.checked }) } - label={page.countryForm.enabled ? "启用" : "停用"} /> ) : null} @@ -206,7 +200,7 @@ function CountryActions({ item, page }) { function CountryStatusSwitch({ item, page }) { return ( - , + render: (item) => , width: "minmax(90px, 0.7fr)", }, { @@ -110,30 +108,6 @@ export function HostHostsPage() { return (
- -
0 + ? { + page: page.page, + pageSize: page.data.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } rowKey={(item) => item.userId} /> - {total > 0 ? ( - - ) : null}
@@ -168,3 +144,13 @@ function HostUser({ item }) { /> ); } + +function HostStatusSwitch({ item }) { + return ( + + ); +} diff --git a/src/features/host-org/pages/HostManagersPage.jsx b/src/features/host-org/pages/HostManagersPage.jsx new file mode 100644 index 0000000..69a3cc2 --- /dev/null +++ b/src/features/host-org/pages/HostManagersPage.jsx @@ -0,0 +1,168 @@ +import { formatMillis } from "@/shared/utils/time.js"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { agencyStatusFilters } from "@/features/host-org/constants.js"; +import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx"; +import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; +import { useHostManagersPage } from "@/features/host-org/hooks/useHostManagersPage.js"; +import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; +import styles from "@/features/host-org/host-org.module.css"; + +const managerColumns = [ + { + key: "ownerUserId", + label: "经理", + render: (item) => , + width: "minmax(220px, 1.25fr)", + }, + { + key: "agency", + label: "Agency", + render: (item) => , + width: "minmax(190px, 1fr)", + }, + { + key: "parentBdUserId", + label: "上级 BD", + render: (item) => , + width: "minmax(220px, 1.2fr)", + }, + { + key: "regionId", + label: "区域", + render: (item, _index, context) => hostOrgRegionName(item, context?.regionOptions), + width: "minmax(130px, 0.75fr)", + }, + { + key: "status", + label: "状态", + render: (item, _index, context) => , + width: "minmax(90px, 0.7fr)", + }, + { + key: "joinEnabled", + label: "入会", + render: (item) => ( + + {item.joinEnabled ? "允许" : "禁止"} + + ), + width: "minmax(80px, 0.6fr)", + }, + { key: "maxHosts", label: "最大主播数", width: "minmax(100px, 0.7fr)" }, + { + key: "updatedAtMs", + label: "更新时间", + render: (item) => formatMillis(item.updatedAtMs), + width: "minmax(170px, 1fr)", + }, +]; + +export function HostManagersPage() { + const page = useHostManagersPage(); + const items = page.data.items || []; + const total = page.data.total || 0; + const columns = managerColumns.map((column) => { + if (column.key === "ownerUserId") { + return { + ...column, + filter: createTextColumnFilter({ + placeholder: "搜索经理、Agency、用户", + value: page.query, + onChange: page.changeQuery, + }), + }; + } + if (column.key === "parentBdUserId") { + return { + ...column, + filter: createTextColumnFilter({ + placeholder: "搜索上级 BD 用户 ID", + value: page.parentBdUserId, + onChange: page.changeParentBdUserId, + }), + }; + } + if (column.key === "status") { + return { + ...column, + filter: createOptionsColumnFilter({ + options: agencyStatusFilters, + placeholder: "搜索状态", + value: page.status, + onChange: page.changeStatus, + }), + }; + } + return column; + }); + + return ( +
+
+ +
+ 0 + ? { + page: page.page, + pageSize: page.data.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(item) => `manager-${item.ownerUserId || item.agencyId}`} + /> +
+
+
+
+ ); +} + +function ManagerUser({ item }) { + return ( + + ); +} + +function ParentBD({ item }) { + if (!item.parentBdUserId) { + return "-"; + } + return ( + + ); +} + +function ManagerStatusSwitch({ item, page }) { + const active = item.status === "active"; + return ( + { + if (!event.target.checked) { + page.closeManagerAgency(item); + } + }} + /> + ); +} diff --git a/src/features/host-org/pages/HostRegionsPage.jsx b/src/features/host-org/pages/HostRegionsPage.jsx index 456a01f..51751e3 100644 --- a/src/features/host-org/pages/HostRegionsPage.jsx +++ b/src/features/host-org/pages/HostRegionsPage.jsx @@ -1,11 +1,10 @@ import Add from "@mui/icons-material/Add"; import EditOutlined from "@mui/icons-material/EditOutlined"; -import Switch from "@mui/material/Switch"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; import { formatMillis } from "@/shared/utils/time.js"; import { DataState } from "@/shared/ui/DataState.jsx"; -import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx"; import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx"; import { regionStatusFilters } from "@/features/host-org/constants.js"; @@ -81,17 +80,8 @@ export function HostRegionsPage() { return (
-
-
-
- -
-
- - {page.abilities.canCreate ? ( + {page.abilities.canCreate ? ( +
@@ -101,8 +91,8 @@ export function HostRegionsPage() {
- ) : null} -
+
+ ) : null}
@@ -118,6 +108,7 @@ export function HostRegionsPage() { disabled={page.activeAction === "edit" ? updateDisabled : createDisabled} loading={page.loadingAction === "region-create" || page.loadingAction === "region-edit"} open={page.activeAction === "create" || page.activeAction === "edit"} + sectionTitle="区域信息" onClose={page.closeAction} onSubmit={page.submitRegion} title={page.activeAction === "edit" ? "编辑区域" : "创建区域"} @@ -182,7 +173,7 @@ function RegionStatusSwitch({ item, page }) { const checked = item.status === "active"; const isGlobal = item.regionCode === "GLOBAL"; return ( - import("./pages/HostManagersPage.jsx").then((module) => module.HostManagersPage), + menuCode: MENU_CODES.hostOrgManagers, + pageKey: "host-org-managers", + path: "/host/managers", + permission: PERMISSIONS.agencyView + }, + { + label: "Agency 列表", loader: () => import("./pages/HostAgenciesPage.jsx").then((module) => module.HostAgenciesPage), menuCode: MENU_CODES.hostOrgAgencies, pageKey: "host-org-agencies", @@ -26,7 +34,7 @@ export const hostOrgRoutes = [ permission: PERMISSIONS.agencyView }, { - label: "BD Leader 管理", + label: "BD Leader 列表", loader: () => import("./pages/HostBdLeadersPage.jsx").then((module) => module.HostBdLeadersPage), menuCode: MENU_CODES.hostOrgBdLeaders, pageKey: "host-org-bd-leaders", @@ -34,7 +42,7 @@ export const hostOrgRoutes = [ permission: PERMISSIONS.bdView }, { - label: "BD 管理", + label: "BD 列表", loader: () => import("./pages/HostBdsPage.jsx").then((module) => module.HostBdsPage), menuCode: MENU_CODES.hostOrgBds, pageKey: "host-org-bds", @@ -42,7 +50,7 @@ export const hostOrgRoutes = [ permission: PERMISSIONS.bdView }, { - label: "主播管理", + label: "主播列表", loader: () => import("./pages/HostHostsPage.jsx").then((module) => module.HostHostsPage), menuCode: MENU_CODES.hostOrgHosts, pageKey: "host-org-hosts", @@ -50,7 +58,7 @@ export const hostOrgRoutes = [ permission: PERMISSIONS.hostView }, { - label: "币商管理", + label: "币商列表", loader: () => import("./pages/HostCoinSellersPage.jsx").then((module) => module.HostCoinSellersPage), menuCode: MENU_CODES.hostOrgCoinSellers, pageKey: "host-org-coin-sellers", diff --git a/src/features/logs/components/LogFilters.jsx b/src/features/logs/components/LogFilters.jsx deleted file mode 100644 index 48b9606..0000000 --- a/src/features/logs/components/LogFilters.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx"; - -export function LogFilters({ onReset, query }) { - return ( -
- -
- ); -} diff --git a/src/features/logs/components/LogTable.jsx b/src/features/logs/components/LogTable.jsx index 7bd8758..895ae22 100644 --- a/src/features/logs/components/LogTable.jsx +++ b/src/features/logs/components/LogTable.jsx @@ -1,7 +1,7 @@ import { DataTable } from "@/shared/ui/DataTable.jsx"; import { createTextColumnFilter } from "@/shared/ui/tableFilters.js"; -export function LogTable({ data, isLogin, onQueryChange, query }) { +export function LogTable({ data, isLogin, onQueryChange, pagination, query }) { const items = data.items || []; const columns = isLogin ? [ @@ -68,9 +68,8 @@ export function LogTable({ data, isLogin, onQueryChange, query }) { columns={columns} items={items} minWidth={isLogin ? "920px" : "1080px"} + pagination={pagination} rowKey={(item) => item.id} - title={isLogin ? "登录记录" : "操作记录"} - total={data.total} /> ); } diff --git a/src/features/logs/pages/LogsPage.jsx b/src/features/logs/pages/LogsPage.jsx index 2480d5a..65b4cbf 100644 --- a/src/features/logs/pages/LogsPage.jsx +++ b/src/features/logs/pages/LogsPage.jsx @@ -1,6 +1,4 @@ import { DataState } from "@/shared/ui/DataState.jsx"; -import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; -import { LogFilters } from "@/features/logs/components/LogFilters.jsx"; import { LogTable } from "@/features/logs/components/LogTable.jsx"; import { LogToolbar } from "@/features/logs/components/LogToolbar.jsx"; import { useLogsPage } from "@/features/logs/hooks/useLogsPage.js"; @@ -11,15 +9,19 @@ export function LogsPage({ type }) { return ( <> - - - diff --git a/src/features/menus/components/MenuFormDrawer.jsx b/src/features/menus/components/MenuFormDrawer.jsx index 9832a2c..a388923 100644 --- a/src/features/menus/components/MenuFormDrawer.jsx +++ b/src/features/menus/components/MenuFormDrawer.jsx @@ -1,6 +1,5 @@ import Drawer from "@mui/material/Drawer"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import Switch from "@mui/material/Switch"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import TextField from "@mui/material/TextField"; import { Button } from "@/shared/ui/Button.jsx"; @@ -9,17 +8,30 @@ export function MenuFormDrawer({ editingMenu, form, onClose, onSubmit, open, set

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

- setForm({ ...form, label: event.target.value })} /> - setForm({ ...form, code: event.target.value })} /> - setForm({ ...form, parentId: event.target.value })} /> - setForm({ ...form, path: event.target.value })} /> - setForm({ ...form, icon: event.target.value })} /> - setForm({ ...form, permissionCode: event.target.value })} /> - setForm({ ...form, sort: event.target.value })} /> - setForm({ ...form, visible: event.target.checked })} />} - label={form.visible ? "显示" : "隐藏"} - /> +
+
基础信息
+
+ setForm({ ...form, label: event.target.value })} /> + setForm({ ...form, code: event.target.value })} /> + setForm({ ...form, parentId: event.target.value })} /> + setForm({ ...form, sort: event.target.value })} /> + setForm({ ...form, visible: event.target.checked })} + /> +
+
+
+
路由权限
+
+ setForm({ ...form, path: event.target.value })} /> + setForm({ ...form, icon: event.target.value })} /> + setForm({ ...form, permissionCode: event.target.value })} /> +
+
diff --git a/src/features/menus/components/PermissionFormDrawer.jsx b/src/features/menus/components/PermissionFormDrawer.jsx index 49d3b00..e654d25 100644 --- a/src/features/menus/components/PermissionFormDrawer.jsx +++ b/src/features/menus/components/PermissionFormDrawer.jsx @@ -9,14 +9,22 @@ export function PermissionFormDrawer({ editingPermission, form, onClose, onSubmi

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

- setForm({ ...form, name: event.target.value })} /> - setForm({ ...form, code: event.target.value })} /> - setForm({ ...form, kind: event.target.value })}> - {permissionKinds.map(([value, label]) => ( - {label} - ))} - - setForm({ ...form, description: event.target.value })} /> +
+
基础信息
+
+ setForm({ ...form, name: event.target.value })} /> + setForm({ ...form, code: event.target.value })} /> + setForm({ ...form, kind: event.target.value })}> + {permissionKinds.map(([value, label]) => ( + {label} + ))} + +
+
+
+
说明
+ setForm({ ...form, description: event.target.value })} /> +
diff --git a/src/features/menus/pages/MenuManagementPage.jsx b/src/features/menus/pages/MenuManagementPage.jsx index 8b5898e..b3d0777 100644 --- a/src/features/menus/pages/MenuManagementPage.jsx +++ b/src/features/menus/pages/MenuManagementPage.jsx @@ -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() { <> - } actions={ <> {page.abilities.canSyncPermission ? ( diff --git a/src/features/notifications/api.ts b/src/features/notifications/api.ts deleted file mode 100644 index f83cba5..0000000 --- a/src/features/notifications/api.ts +++ /dev/null @@ -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 { - return apiRequest(apiEndpointPath(API_OPERATIONS.listNotifications)); -} - -export function markNotificationRead(id: EntityId): Promise { - const endpoint = API_ENDPOINTS.markNotificationRead; - return apiRequest>(apiEndpointPath(API_OPERATIONS.markNotificationRead, { id }), { - body: {}, - method: endpoint.method - }); -} - -export function markAllNotificationsRead(): Promise { - const endpoint = API_ENDPOINTS.markAllNotificationsRead; - return apiRequest>(apiEndpointPath(API_OPERATIONS.markAllNotificationsRead), { - body: {}, - method: endpoint.method - }); -} - -export function deleteNotification(id: EntityId): Promise { - const endpoint = API_ENDPOINTS.deleteNotification; - return apiRequest(apiEndpointPath(API_OPERATIONS.deleteNotification, { id }), { method: endpoint.method }); -} diff --git a/src/features/notifications/components/NotificationList.jsx b/src/features/notifications/components/NotificationList.jsx deleted file mode 100644 index c625ba2..0000000 --- a/src/features/notifications/components/NotificationList.jsx +++ /dev/null @@ -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 ( - -
- {(items || []).map((item) => ( -
-
-
{item.title}
-
{item.content}
-
{formatDate(item.createdAt)}
-
-
- {!item.readAt && abilities.canRead ? ( - - ) : ( - {item.readAt ? "已读" : "未读"} - )} - {abilities.canDelete ? ( - - ) : null} -
-
- ))} - {!items?.length ?
暂无通知
: null} -
-
- ); -} - -function formatDate(value) { - if (!value) { - return "-"; - } - return new Date(value).toLocaleString("zh-CN", { hour12: false }); -} diff --git a/src/features/notifications/components/NotificationToolbar.jsx b/src/features/notifications/components/NotificationToolbar.jsx deleted file mode 100644 index 9777085..0000000 --- a/src/features/notifications/components/NotificationToolbar.jsx +++ /dev/null @@ -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 ( - - {canReadAll ? ( - - ) : null} - - ); -} diff --git a/src/features/notifications/hooks/useNotificationsPage.js b/src/features/notifications/hooks/useNotificationsPage.js deleted file mode 100644 index c37697e..0000000 --- a/src/features/notifications/hooks/useNotificationsPage.js +++ /dev/null @@ -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 - }; -} diff --git a/src/features/notifications/notifications.css b/src/features/notifications/notifications.css deleted file mode 100644 index 5e740be..0000000 --- a/src/features/notifications/notifications.css +++ /dev/null @@ -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); -} diff --git a/src/features/notifications/pages/NotificationsPage.jsx b/src/features/notifications/pages/NotificationsPage.jsx deleted file mode 100644 index 7ee1927..0000000 --- a/src/features/notifications/pages/NotificationsPage.jsx +++ /dev/null @@ -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 ( - <> - - - - - - ); -} diff --git a/src/features/notifications/permissions.js b/src/features/notifications/permissions.js deleted file mode 100644 index 58316ef..0000000 --- a/src/features/notifications/permissions.js +++ /dev/null @@ -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) - }; -} diff --git a/src/features/notifications/routes.js b/src/features/notifications/routes.js deleted file mode 100644 index e79c24f..0000000 --- a/src/features/notifications/routes.js +++ /dev/null @@ -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 - } -]; diff --git a/src/features/operations/api.ts b/src/features/operations/api.ts new file mode 100644 index 0000000..bfd4efd --- /dev/null +++ b/src/features/operations/api.ts @@ -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> { + const endpoint = API_ENDPOINTS.listCoinLedger; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listCoinLedger), { + method: endpoint.method, + query, + }); +} diff --git a/src/features/operations/operations.module.css b/src/features/operations/operations.module.css new file mode 100644 index 0000000..2d1782d --- /dev/null +++ b/src/features/operations/operations.module.css @@ -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; + } +} diff --git a/src/features/operations/pages/CoinLedgerPage.jsx b/src/features/operations/pages/CoinLedgerPage.jsx new file mode 100644 index 0000000..ed31a9d --- /dev/null +++ b/src/features/operations/pages/CoinLedgerPage.jsx @@ -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) => , + }, + { + key: "amount", + label: "数量", + width: "minmax(120px, 0.65fr)", + render: (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 ( + + + + + + } + /> + + + coinLedgerRowProps(entry, setSelectedEntry)} + items={items} + minWidth="900px" + pagination={ + total > 0 + ? { + page, + pageSize: data.pageSize || pageSize, + total, + onPageChange: setPage, + } + : undefined + } + rowKey={(entry) => entry.entryId} + /> + + + + + ); +} + +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 ( + + {entry ? ( + <> +
+ + {user.avatar ? : } + +
+
+

{user.username || "-"}

+ +
+ {userIdText(entry)} +
+
+ + + + + + + + + ) : null} +
+ ); +} + +function UserCell({ entry }) { + const user = entry.user || {}; + return ( +
+ + {user.avatar ? : } + +
+ {user.username || "-"} + {userIdText(entry)} +
+
+ ); +} + +function AmountValue({ entry }) { + const expense = entry.direction === "expense"; + return ( + + {formatSignedAmount(entry)} + + ); +} + +function DetailSection({ items, title }) { + const visibleItems = items.filter((item) => hasDetailValue(item.value)); + if (!visibleItems.length) { + return null; + } + return ( +
+

{title}

+
+ {visibleItems.map((item) => ( + + ))} +
+
+ ); +} + +function DetailItem({ label, value }) { + return ( +
+ {label} +
{displayDetailValue(value)}
+
+ ); +} + +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
{JSON.stringify(value, null, 2)}
; + } + 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 || "-"; +} diff --git a/src/features/operations/routes.js b/src/features/operations/routes.js new file mode 100644 index 0000000..75e910f --- /dev/null +++ b/src/features/operations/routes.js @@ -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, + }, +]; diff --git a/src/features/payment/api.ts b/src/features/payment/api.ts index 506bdee..4c9a0c5 100644 --- a/src/features/payment/api.ts +++ b/src/features/payment/api.ts @@ -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> { - const endpoint = API_ENDPOINTS.listRechargeBills; - return apiRequest>(apiEndpointPath(API_OPERATIONS.listRechargeBills), { - method: endpoint.method, - query - }); + const endpoint = API_ENDPOINTS.listRechargeBills; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listRechargeBills), { + method: endpoint.method, + query, + }); +} + +export function listRechargeProducts(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listRechargeProducts; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listRechargeProducts), { + method: endpoint.method, + query, + }); +} + +export function createRechargeProduct(payload: RechargeProductPayload): Promise { + const endpoint = API_ENDPOINTS.createRechargeProduct; + return apiRequest( + apiEndpointPath(API_OPERATIONS.createRechargeProduct), + { + body: payload, + method: endpoint.method, + }, + ); +} + +export function updateRechargeProduct( + productId: EntityId, + payload: RechargeProductPayload, +): Promise { + const endpoint = API_ENDPOINTS.updateRechargeProduct; + return apiRequest( + apiEndpointPath(API_OPERATIONS.updateRechargeProduct, { product_id: productId }), + { + body: payload, + method: endpoint.method, + }, + ); +} + +export function deleteRechargeProduct(productId: EntityId): Promise { + const endpoint = API_ENDPOINTS.deleteRechargeProduct; + return apiRequest(apiEndpointPath(API_OPERATIONS.deleteRechargeProduct, { product_id: productId }), { + method: endpoint.method, + }); } diff --git a/src/features/payment/hooks/useRechargeProductsPage.js b/src/features/payment/hooks/useRechargeProductsPage.js new file mode 100644 index 0000000..c5801f9 --- /dev/null +++ b/src/features/payment/hooks/useRechargeProductsPage.js @@ -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), + }; +} diff --git a/src/features/payment/pages/PaymentBillListPage.jsx b/src/features/payment/pages/PaymentBillListPage.jsx index ccca972..7ba94f8 100644 --- a/src/features/payment/pages/PaymentBillListPage.jsx +++ b/src/features/payment/pages/PaymentBillListPage.jsx @@ -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 ( - - } - /> 0 + ? { + page, + pageSize: data.pageSize || pageSize, + total, + onPageChange: setPage, + } + : undefined + } rowKey={(bill) => bill.transactionId} /> - {total > 0 ? ( - - ) : null} diff --git a/src/features/payment/pages/RechargeProductConfigPage.jsx b/src/features/payment/pages/RechargeProductConfigPage.jsx new file mode 100644 index 0000000..8ec5fc2 --- /dev/null +++ b/src/features/payment/pages/RechargeProductConfigPage.jsx @@ -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 ( + + } + variant="primary" + onClick={page.openCreate} + > + 新增内购 + + ) : null + } + /> + + + item.productId} + /> + + + + + + + page.setForm({ productName: event.target.value })} + /> + page.setForm({ platform: event.target.value })} + > + Android + iOS + + + + + + page.setForm({ amountUsdt: event.target.value })} + /> + page.setForm({ coinAmount: event.target.value })} + /> + + + + page.setForm({ regionIds })} + /> + page.setForm({ enabled: checked })} + /> + + + page.setForm({ description: event.target.value })} + /> + + + + ); +} + +function productColumns(page, regionLabels) { + const columns = [ + { + key: "product", + label: "产品", + render: (item) => , + filter: createTextColumnFilter({ + placeholder: "搜索产品、描述", + value: page.keyword, + onChange: page.setKeyword, + }), + width: "minmax(240px, 1.2fr)", + }, + { + key: "amount", + label: "金额 / 金币", + render: (item) => ( + + ), + 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) => , + 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) => , + width: "minmax(96px, 0.45fr)", + }, + ]; +} + +function ProductActions({ item, page }) { + const deleting = page.loadingAction === `delete:${item.productId}`; + return ( + + {page.abilities.canUpdateProduct ? ( + page.openEdit(item)} + > + + + ) : null} + {page.abilities.canDeleteProduct ? ( + page.removeProduct(item)} + > + + + ) : null} + + ); +} + +function Stack({ primary, secondary }) { + return ( +
+ {primary || "-"} + {secondary || "-"} +
+ ); +} + +function StatusBadge({ enabled }) { + const tone = enabled ? "succeeded" : "stopped"; + return ( + + + {enabled ? "上架" : "下架"} + + ); +} + +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+$/, ""); +} diff --git a/src/features/payment/permissions.js b/src/features/payment/permissions.js new file mode 100644 index 0000000..1c8344d --- /dev/null +++ b/src/features/payment/permissions.js @@ -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), + }; +} diff --git a/src/features/payment/routes.js b/src/features/payment/routes.js index 5e3e57e..5e61de8 100644 --- a/src/features/payment/routes.js +++ b/src/features/payment/routes.js @@ -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, + } ]; diff --git a/src/features/payment/schema.ts b/src/features/payment/schema.ts new file mode 100644 index 0000000..35b714b --- /dev/null +++ b/src/features/payment/schema.ts @@ -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; diff --git a/src/features/registration-reward/api.ts b/src/features/registration-reward/api.ts new file mode 100644 index 0000000..766f7bb --- /dev/null +++ b/src/features/registration-reward/api.ts @@ -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; +type RawClaim = RegistrationRewardClaimDto & Record; + +export function getRegistrationRewardConfig(): Promise { + const endpoint = API_ENDPOINTS.getRegistrationRewardConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.getRegistrationRewardConfig), { + method: endpoint.method, + }).then(normalizeConfig); +} + +export function updateRegistrationRewardConfig( + payload: RegistrationRewardConfigPayload, +): Promise { + const endpoint = API_ENDPOINTS.updateRegistrationRewardConfig; + return apiRequest( + apiEndpointPath(API_OPERATIONS.updateRegistrationRewardConfig), + { + body: payload, + method: endpoint.method, + }, + ).then(normalizeConfig); +} + +export function listRegistrationRewardClaims(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listRegistrationRewardClaims; + return apiRequest>(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; + 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; +} diff --git a/src/features/registration-reward/hooks/useRegistrationRewardPage.js b/src/features/registration-reward/hooks/useRegistrationRewardPage.js new file mode 100644 index 0000000..6ab0328 --- /dev/null +++ b/src/features/registration-reward/hooks/useRegistrationRewardPage.js @@ -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, + }; +} diff --git a/src/features/registration-reward/pages/RegistrationRewardPage.jsx b/src/features/registration-reward/pages/RegistrationRewardPage.jsx new file mode 100644 index 0000000..3285776 --- /dev/null +++ b/src/features/registration-reward/pages/RegistrationRewardPage.jsx @@ -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) => , + }, + { + key: "reward", + label: "奖励", + width: "minmax(180px, 0.85fr)", + render: (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) => ( +
+ {claim.claimId} + {claim.rewardDay || "-"} +
+ ), + }, + { + 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 ( + + +
+
+ 注册奖励配置 + 注册成功后由服务端自动发放 +
+
+ + + + +
+
+
+
+
发放状态
+
+ + page.setForm((current) => ({ ...current, enabled: event.target.checked })) + } + /> + + page.setForm((current) => ({ ...current, dailyLimit: event.target.value })) + } + /> +
+
+
+
奖励内容
+
+ + page.setForm((current) => ({ ...current, rewardType: event.target.value })) + } + > + {rewardTypeOptions.map(([value, label]) => ( + + {label} + + ))} + + {page.form.rewardType === "resource_group" ? ( + + page.setForm((current) => ({ + ...current, + resourceGroupId: event.target.value, + })) + } + > + 请选择资源组 + {page.resourceGroups.map((group) => ( + + {group.name || group.groupCode || group.groupId} + + ))} + + ) : ( + + page.setForm((current) => ({ ...current, coinAmount: event.target.value })) + } + /> + )} +
+
+
+ + + + + } + /> + + + 0 + ? { + page: page.page, + pageSize: page.claims.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(claim) => claim.claimId} + /> + + +
+ ); +} + +function ClaimUser({ claim }) { + const user = claim.user || {}; + return ( +
+ +
+ {user.username || `用户 ${claim.userId}`} + {user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${claim.userId}`} +
+
+ ); +} + +function RewardSummary({ claim }) { + if (claim.rewardType === "resource_group") { + return ( +
+ 资源组 + #{claim.resourceGroupId || "-"} +
+ ); + } + return ( +
+ {formatNumber(claim.coinAmount)} 金币 + 注册奖励 +
+ ); +} + +function claimStatusLabel(status) { + return claimStatusOptions.find(([value]) => value === status)?.[1] || status || "-"; +} + +function formatNumber(value) { + return new Intl.NumberFormat("zh-CN").format(Number(value || 0)); +} diff --git a/src/features/registration-reward/permissions.js b/src/features/registration-reward/permissions.js new file mode 100644 index 0000000..3f90eaa --- /dev/null +++ b/src/features/registration-reward/permissions.js @@ -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), + }; +} diff --git a/src/features/registration-reward/registration-reward.module.css b/src/features/registration-reward/registration-reward.module.css new file mode 100644 index 0000000..1d9d46b --- /dev/null +++ b/src/features/registration-reward/registration-reward.module.css @@ -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; + } +} diff --git a/src/features/registration-reward/routes.js b/src/features/registration-reward/routes.js new file mode 100644 index 0000000..80850ff --- /dev/null +++ b/src/features/registration-reward/routes.js @@ -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, + }, +]; diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index 992b8dc..a814760 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -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; diff --git a/src/features/resources/constants.js b/src/features/resources/constants.js index 9b841a4..27ba9d4 100644 --- a/src/features/resources/constants.js +++ b/src/features/resources/constants.js @@ -26,6 +26,7 @@ export const resourceGrantSubjectLabels = { export const resourceTypeFilters = [ ["", "全部类型"], ["avatar_frame", "头像框"], + ["profile_card", "资料卡"], ["coin", "金币"], ["vehicle", "座驾"], ["chat_bubble", "气泡"], diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 097c14f..6e68a41 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -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, diff --git a/src/features/resources/hooks/useResourcePages.test.js b/src/features/resources/hooks/useResourcePages.test.js new file mode 100644 index 0000000..b4b44ab --- /dev/null +++ b/src/features/resources/hooks/useResourcePages.test.js @@ -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", + }); +}); diff --git a/src/features/resources/pages/GiftListPage.jsx b/src/features/resources/pages/GiftListPage.jsx index 97a58f5..2144ecf 100644 --- a/src/features/resources/pages/GiftListPage.jsx +++ b/src/features/resources/pages/GiftListPage.jsx @@ -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 ( - } actions={ page.abilities.canCreateGift ? ( @@ -198,15 +194,22 @@ export function GiftListPage() { /> - gift.giftId} /> - {total > 0 ? ( - - ) : null} + 0 + ? { + page: page.page, + pageSize: page.data.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(gift) => gift.giftId} + /> - - page.setForm({ ...form, name: event.target.value })} - /> - page.setForm({ ...form, giftId: event.target.value })} - /> - page.setForm({ ...form, giftTypeCode: event.target.value })} - > - {giftTypeOptions.map(([value, label]) => ( - - {label} - - ))} - - page.setForm({ ...form, resourceId: event.target.value })} - > - {page.resourceOptions.map((resource) => ( - - {resource.name || resource.resourceCode || resource.resourceId} - - ))} - - page.setForm({ ...form, chargeAssetType: event.target.value })} - > - {chargeAssetOptions.map(([value, label]) => ( - - {label} - - ))} - - page.setForm({ ...form, coinPrice: event.target.value })} - /> - page.setForm({ ...form, giftPointAmount: event.target.value })} - /> - page.setForm({ ...form, heatValue: event.target.value })} - /> - page.setForm({ ...form, sortOrder: event.target.value })} - /> - page.setForm({ ...form, regionIds })} - /> - page.setForm({ ...form, effectiveFrom })} - /> - page.setForm({ ...form, effectiveTo })} - /> - page.setForm({ ...form, effectTypes })} - /> - page.setForm({ ...form, enabled: event.target.checked })} - /> - } - label={form.enabled ? "启用" : "禁用"} - /> - + + + page.selectGiftResource(event.target.value)} + > + {page.resourceOptions.map((resource) => ( + + {resource.name || resource.resourceCode || resource.resourceId} + + ))} + + {showGiftIdentityFields ? ( + <> + page.setForm({ ...form, name: event.target.value })} + /> + page.setForm({ ...form, giftId: event.target.value })} + /> + + ) : null} + page.setForm({ ...form, giftTypeCode: event.target.value })} + > + {giftTypeOptions.map(([value, label]) => ( + + {label} + + ))} + + + + + + page.setForm({ ...form, chargeAssetType: event.target.value })} + > + {chargeAssetOptions.map(([value, label]) => ( + + {label} + + ))} + + page.changeGiftPrice(event.target.value)} + /> + page.setForm({ ...form, giftPointAmount: event.target.value })} + /> + page.setForm({ ...form, heatValue: event.target.value })} + /> + page.setForm({ ...form, sortOrder: event.target.value })} + /> + page.setForm({ ...form, enabled: checked })} + /> + + + + + page.setForm({ ...form, regionIds })} + /> + page.setForm({ ...form, effectiveFrom })} + /> + page.setForm({ ...form, effectiveTo })} + /> + page.setForm({ ...form, effectTypes })} + /> + + ); } diff --git a/src/features/resources/pages/ResourceGrantListPage.jsx b/src/features/resources/pages/ResourceGrantListPage.jsx index 9888a0e..76033b7 100644 --- a/src/features/resources/pages/ResourceGrantListPage.jsx +++ b/src/features/resources/pages/ResourceGrantListPage.jsx @@ -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) => , + }, { key: "subject", label: "对象", @@ -114,9 +119,6 @@ export function ResourceGrantListPage() { return ( - } actions={ page.abilities.canCreateGrant ? ( @@ -130,17 +132,19 @@ export function ResourceGrantListPage() { 0 + ? { + page: page.page, + pageSize: page.data.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } rowKey={(grant) => grant.grantId} /> - {total > 0 ? ( - - ) : null} - setForm({ ...form, targetUserId: event.target.value })} - /> - setForm({ ...form, groupId: "", resourceId: "", subjectType: event.target.value })} - > - 资源 - 资源组 - - {form.subjectType === "resource" ? ( - <> + + setForm({ ...form, targetUserId: event.target.value })} + /> + + + setForm({ ...form, resourceId: event.target.value })} + value={form.subjectType} + onChange={(event) => + setForm({ ...form, groupId: "", resourceId: "", subjectType: event.target.value }) + } > - {resourceOptions.map((resource) => ( - - {resource.name || resource.resourceCode || resource.resourceId} - - ))} + 资源 + 资源组 - setForm({ ...form, quantity: event.target.value })} - /> - setForm({ ...form, durationDays: event.target.value })} - /> - - ) : ( + {form.subjectType === "resource" ? ( + <> + setForm({ ...form, resourceId: event.target.value })} + > + {resourceOptions.map((resource) => ( + + {resource.name || resource.resourceCode || resource.resourceId} + + ))} + + setForm({ ...form, quantity: event.target.value })} + /> + setForm({ ...form, durationDays: event.target.value })} + /> + + ) : ( + setForm({ ...form, groupId: event.target.value })} + > + {groupOptions.map((group) => ( + + {group.name || group.groupCode || group.groupId} + + ))} + + )} + + + setForm({ ...form, groupId: event.target.value })} - > - {groupOptions.map((group) => ( - - {group.name || group.groupCode || group.groupId} - - ))} - - )} - setForm({ ...form, reason: event.target.value })} - /> + value={form.reason} + onChange={(event) => setForm({ ...form, reason: event.target.value })} + /> + ); } @@ -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 ( +
+ + {operator.avatar ? ( + + ) : ( + + )} + + + {name} + {shortId} + +
+ ); + } + if (source === "admin") { + const name = operator.name || operator.username || (operatorUserId ? `Admin ${operatorUserId}` : "-"); + return ( +
+ {name} + {operator.username || operatorUserId || "-"} +
+ ); + } + if (!operatorUserId) { + return "-"; + } + return ( +
+ {operatorUserId} + {source || "-"} +
+ ); +} + function formatDuration(durationMs) { const days = Number(durationMs || 0) / (24 * 60 * 60 * 1000); return Number.isInteger(days) ? `${days}天` : `${days.toFixed(1)}天`; diff --git a/src/features/resources/pages/ResourceGroupListPage.jsx b/src/features/resources/pages/ResourceGroupListPage.jsx index 7bec484..e269b18 100644 --- a/src/features/resources/pages/ResourceGroupListPage.jsx +++ b/src/features/resources/pages/ResourceGroupListPage.jsx @@ -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 ( - } actions={ page.abilities.canCreateGroup ? ( @@ -123,15 +118,22 @@ export function ResourceGroupListPage() { /> - group.groupId} /> - {total > 0 ? ( - - ) : null} + 0 + ? { + page: page.page, + pageSize: page.data.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(group) => group.groupId} + /> - - page.setForm({ ...form, name: event.target.value })} - /> - page.setForm({ ...form, groupCode: event.target.value })} - /> - page.setForm({ ...form, sortOrder: event.target.value })} - /> - - page.setForm({ ...form, description: event.target.value })} - /> - page.setForm({ ...form, enabled: event.target.checked })} + + + page.setForm({ ...form, name: event.target.value })} /> - } - label={form.enabled ? "启用" : "禁用"} - /> + page.setForm({ ...form, groupCode: event.target.value })} + /> + page.setForm({ ...form, sortOrder: event.target.value })} + /> + + page.setForm({ ...form, description: event.target.value })} + /> + page.setForm({ ...form, enabled: checked })} + /> + diff --git a/src/features/roles/hooks/useRolesPage.js b/src/features/roles/hooks/useRolesPage.js index 9e6aacb..1c0281f 100644 --- a/src/features/roles/hooks/useRolesPage.js +++ b/src/features/roles/hooks/useRolesPage.js @@ -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: "通用能力" }, ]; diff --git a/src/features/roles/pages/RoleManagementPage.jsx b/src/features/roles/pages/RoleManagementPage.jsx index d932f10..dbd0065 100644 --- a/src/features/roles/pages/RoleManagementPage.jsx +++ b/src/features/roles/pages/RoleManagementPage.jsx @@ -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() { <> - } actions={ page.abilities.canCreate ? ( diff --git a/src/features/rooms/api.ts b/src/features/rooms/api.ts index 0e55875..ab7935a 100644 --- a/src/features/rooms/api.ts +++ b/src/features/rooms/api.ts @@ -4,15 +4,17 @@ import type { ApiPage, EntityId, PageQuery, + RoomConfigDto, + RoomConfigPayload, RoomDto, - RoomUpdatePayload + RoomUpdatePayload, } from "@/shared/api/types"; export function listRooms(query: PageQuery = {}): Promise> { const endpoint = API_ENDPOINTS.listRooms; return apiRequest>(apiEndpointPath(API_OPERATIONS.listRooms), { method: endpoint.method, - query + query, }); } @@ -20,13 +22,28 @@ export function updateRoom(roomId: EntityId, payload: RoomUpdatePayload): Promis const endpoint = API_ENDPOINTS.updateRoom; return apiRequest(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 { + const endpoint = API_ENDPOINTS.getRoomConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.getRoomConfig), { + method: endpoint.method, + }); +} + +export function updateRoomConfig(payload: RoomConfigPayload): Promise { + const endpoint = API_ENDPOINTS.updateRoomConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.updateRoomConfig), { + body: payload, + method: endpoint.method, }); } diff --git a/src/features/rooms/components/RoomDetailDrawer.jsx b/src/features/rooms/components/RoomDetailDrawer.jsx index ac3c6e1..bbd7f93 100644 --- a/src/features/rooms/components/RoomDetailDrawer.jsx +++ b/src/features/rooms/components/RoomDetailDrawer.jsx @@ -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 ( @@ -23,18 +25,17 @@ export function RoomDetailDrawer({ onClose, open, room }) {

{room.title || "-"}

- 房间 ID {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 }) {
- - +
@@ -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 (
{label} - {displayValue(value)} +
{displayValue(value)}
); } +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 ( + + ); +} + 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 || "-"; } diff --git a/src/features/rooms/hooks/useRoomConfigPage.js b/src/features/rooms/hooks/useRoomConfigPage.js new file mode 100644 index 0000000..f8f82f5 --- /dev/null +++ b/src/features/rooms/hooks/useRoomConfigPage.js @@ -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) : []; +} diff --git a/src/features/rooms/hooks/useRoomsPage.js b/src/features/rooms/hooks/useRoomsPage.js index 4785b42..6926a9c 100644 --- a/src/features/rooms/hooks/useRoomsPage.js +++ b/src/features/rooms/hooks/useRoomsPage.js @@ -26,6 +26,7 @@ export function useRoomsPage() { const [query, setQuery] = useState(""); const [status, setStatus] = useState(""); const [regionId, setRegionId] = useState(""); + const [contributionSortDirection, setContributionSortDirection] = useState(""); const [page, setPage] = useState(1); const [activeAction, setActiveAction] = useState(""); const [activeRoom, setActiveRoom] = useState(null); @@ -36,9 +37,11 @@ export function useRoomsPage() { () => ({ keyword: query, region_id: regionId, + sort_by: contributionSortDirection ? "room_contribution" : "", + sort_direction: contributionSortDirection, status, }), - [query, regionId, status], + [contributionSortDirection, query, regionId, status], ); const { data = emptyData, @@ -69,6 +72,11 @@ export function useRoomsPage() { setPage(1); }; + const toggleContributionSort = () => { + setContributionSortDirection((current) => (current === "desc" ? "asc" : "desc")); + setPage(1); + }; + const resetFilters = () => { setQuery(""); setStatus(""); @@ -168,6 +176,7 @@ export function useRoomsPage() { changeRegionId, changeStatus, closeAction, + contributionSortDirection, data, error, form, @@ -191,5 +200,6 @@ export function useRoomsPage() { statusForm, submitStatus, submitEdit, + toggleContributionSort, }; } diff --git a/src/features/rooms/pages/RoomConfigPage.jsx b/src/features/rooms/pages/RoomConfigPage.jsx new file mode 100644 index 0000000..4d65bf9 --- /dev/null +++ b/src/features/rooms/pages/RoomConfigPage.jsx @@ -0,0 +1,88 @@ +import EventSeatOutlined from "@mui/icons-material/EventSeatOutlined"; +import SaveOutlined from "@mui/icons-material/SaveOutlined"; +import Checkbox from "@mui/material/Checkbox"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { AdminActionIconButton, AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; +import { useRoomConfigPage } from "@/features/rooms/hooks/useRoomConfigPage.js"; +import styles from "@/features/rooms/rooms.module.css"; + +export function RoomConfigPage() { + const page = useRoomConfigPage(); + const canSave = page.abilities.canUpdateConfig && page.loadingAction !== "save"; + const defaultValue = page.form.allowedSeatCounts.includes(page.form.defaultSeatCount) + ? page.form.defaultSeatCount + : ""; + + return ( + + + + + ) : null + } + /> + + + +
+
+ + + +
+

座位数

+ + {page.form.updatedAtMs ? formatMillis(page.form.updatedAtMs) : "默认配置"} + +
+
+ +
+ {page.form.candidateSeatCounts.map((seatCount) => ( + page.toggleSeatCount(seatCount)} + /> + } + label={`${seatCount} 个`} + /> + ))} +
+ + page.changeDefaultSeatCount(event.target.value)} + > + {page.form.allowedSeatCounts.map((seatCount) => ( + + {seatCount} 个 + + ))} + +
+ +
+
+
+ ); +} diff --git a/src/features/rooms/pages/RoomListPage.jsx b/src/features/rooms/pages/RoomListPage.jsx index f6a4931..a6f879f 100644 --- a/src/features/rooms/pages/RoomListPage.jsx +++ b/src/features/rooms/pages/RoomListPage.jsx @@ -2,15 +2,12 @@ import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import EditOutlined from "@mui/icons-material/EditOutlined"; import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined"; import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined"; -import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; -import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx"; +import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx"; -import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; -import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx"; import { RegionSelect } from "@/shared/ui/RegionSelect.jsx"; import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; @@ -38,7 +35,7 @@ const columns = [ key: "region", label: "区域", width: "minmax(140px, 0.8fr)", - render: (room) => room.regionName || (room.visibleRegionId ? `区域 ${room.visibleRegionId}` : "-"), + render: (room) => room.regionName || "-", }, { key: "onlineCount", @@ -46,6 +43,12 @@ const columns = [ width: "minmax(110px, 0.7fr)", render: (room) => `${formatNumber(room.onlineCount)} 人`, }, + { + key: "roomContribution", + label: "房间贡献", + width: "minmax(120px, 0.8fr)", + render: (room) => formatNumber(roomContributionValue(room)), + }, { key: "status", label: "状态", @@ -86,7 +89,7 @@ export function RoomListPage() { }), render: (room) => page.openDetail(room)} />, } - : column.key === "status" + : column.key === "status" ? { ...column, filter: createOptionsColumnFilter({ @@ -95,14 +98,18 @@ export function RoomListPage() { value: page.status, onChange: page.changeStatus, }), - render: (room) => ( - page.openStatus(room)} - /> - ), + render: (room) => , } + : column.key === "roomContribution" + ? { + ...column, + header: ( + + ), + } : column, ), { @@ -116,31 +123,24 @@ export function RoomListPage() { return (
-
-
- -
-
-
0 + ? { + page: page.page, + pageSize: page.data.pageSize || 10, + total, + onPageChange: page.setPage, + } + : undefined + } rowKey={(room) => room.roomId} /> - {total > 0 ? ( - - ) : null}
@@ -151,6 +151,7 @@ export function RoomListPage() { disabled={!page.abilities.canUpdate} loading={page.loadingAction === "edit"} open={page.activeAction === "edit"} + sectionTitle="房间信息" title="编辑房间" onClose={page.closeAction} onSubmit={page.submitEdit} @@ -187,26 +188,6 @@ export function RoomListPage() { /> - - page.setStatusForm({ status: event.target.value })} - > - 正常 - 关闭 - -
); } @@ -219,7 +200,6 @@ function RoomIdentity({ onClick, room }) {
{room.title || "-"}
-
{room.roomId}
); @@ -230,7 +210,7 @@ function RoomIdentity({ onClick, room }) { return ( - ); } -function ActionModal({ children, disabled, loading, onClose, onSubmit, open, title }) { +function ActionModal({ children, disabled, loading, onClose, onSubmit, open, sectionTitle = "基本信息", title }) { return ( - {children} + {children} ); } @@ -326,3 +298,32 @@ function ActionModal({ children, disabled, loading, onClose, onSubmit, open, tit function formatNumber(value) { return Number(value || 0).toLocaleString("zh-CN"); } + +function roomContributionValue(room) { + return room.roomContribution ?? room.heat ?? 0; +} + +function ContributionSortHeader({ direction, onClick }) { + const active = direction === "asc" || direction === "desc"; + const arrow = direction === "asc" ? "↑" : "↓"; + return ( + + ); +} + +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 || "-"; +} diff --git a/src/features/rooms/permissions.js b/src/features/rooms/permissions.js index f0da236..0715d88 100644 --- a/src/features/rooms/permissions.js +++ b/src/features/rooms/permissions.js @@ -2,12 +2,14 @@ import { useAuth } from "@/app/auth/AuthProvider.jsx"; import { PERMISSIONS } from "@/app/permissions"; export function useRoomAbilities() { - const { can } = useAuth(); + const { can } = useAuth(); - return { - canDelete: can(PERMISSIONS.roomDelete), - canUpdate: can(PERMISSIONS.roomUpdate), - canUpload: can(PERMISSIONS.uploadCreate), - canView: can(PERMISSIONS.roomView) - }; + return { + canUpdateConfig: can(PERMISSIONS.roomConfigUpdate), + canViewConfig: can(PERMISSIONS.roomConfigView), + canDelete: can(PERMISSIONS.roomDelete), + canUpdate: can(PERMISSIONS.roomUpdate), + canUpload: can(PERMISSIONS.uploadCreate), + canView: can(PERMISSIONS.roomView), + }; } diff --git a/src/features/rooms/rooms.module.css b/src/features/rooms/rooms.module.css index 28d9b96..4591140 100644 --- a/src/features/rooms/rooms.module.css +++ b/src/features/rooms/rooms.module.css @@ -1,368 +1,519 @@ .root { - display: flex; - height: calc(100vh - var(--header-height) - var(--space-8) - var(--space-4)); - height: calc(100dvh - var(--header-height) - var(--space-8) - var(--space-4)); - min-height: 0; - flex-direction: column; + display: flex; + height: calc(100vh - var(--header-height) - var(--space-8) - var(--space-4)); + height: calc(100dvh - var(--header-height) - var(--space-8) - var(--space-4)); + min-height: 0; + flex-direction: column; } .contentPanel { - display: flex; - flex: 1; - flex-direction: column; - min-height: 0; - overflow: hidden; - border: 1px solid var(--border); - border-radius: var(--radius-card); - background: var(--bg-card); + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); } .contentPanel :global(.data-state), .contentPanel :global(.table-frame) { - flex: 1; - min-height: 0; - border: 0; - border-radius: 0; - background: transparent; + flex: 1; + min-height: 0; + border: 0; + border-radius: 0; + background: transparent; } .contentPanel :global(.table-scroll) { - overflow: auto; + overflow: auto; } .toolbar { - display: flex; - align-items: center; - gap: var(--space-4); - padding: var(--space-4) var(--space-5); - border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: var(--space-4); + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--border); } .filters { - display: flex; - min-width: 0; - flex: 1; - align-items: center; - flex-wrap: wrap; - gap: var(--space-3); + display: flex; + min-width: 0; + flex: 1; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); } .search { - flex: 0 1 320px; + flex: 0 1 320px; } .statusSelect { - flex: 0 0 150px; + flex: 0 0 150px; } .regionInput { - flex: 0 0 auto; + flex: 0 0 auto; } .listBlock { - display: flex; - flex: 1; - min-height: 0; - flex-direction: column; - background: var(--bg-card); + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + background: var(--bg-card); } .listBlock :global(.pagination-bar) { - margin: var(--space-3) var(--space-5) var(--space-4); + margin: var(--space-3) var(--space-5) var(--space-4); } .identity { - display: flex; - min-width: 0; - align-items: center; - gap: var(--space-3); + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-3); } .identityButton { - display: block; - width: 100%; - padding: 2px; - border: 0; - border-radius: var(--radius-control); - background: transparent; - text-align: left; - cursor: pointer; - transition: - background var(--motion-base) var(--ease-standard), - box-shadow var(--motion-base) var(--ease-standard), - transform var(--motion-base) var(--ease-emphasized); + display: block; + width: 100%; + padding: 2px; + border: 0; + border-radius: var(--radius-control); + background: transparent; + text-align: left; + cursor: pointer; + transition: + background var(--motion-base) var(--ease-standard), + box-shadow var(--motion-base) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); } .identityButton:hover { - background: var(--bg-hover); + background: var(--bg-hover); } .identityButton:hover .name { - color: var(--primary); + color: var(--primary); } .identityButton:focus-visible { - outline: 2px solid var(--primary); - outline-offset: 2px; + outline: 2px solid var(--primary); + outline-offset: 2px; } .cover { - display: inline-flex; - width: 36px; - height: 36px; - flex: 0 0 auto; - align-items: center; - justify-content: center; - overflow: hidden; - border: 1px solid var(--border); - border-radius: var(--radius-control); - background: var(--bg-card-strong); - color: var(--text-secondary); + display: inline-flex; + width: 36px; + height: 36px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong); + color: var(--text-secondary); } .cover img { - width: 100%; - height: 100%; - object-fit: cover; + width: 100%; + height: 100%; + object-fit: cover; } .ownerAvatar { - display: inline-flex; - width: 36px; - height: 36px; - flex: 0 0 auto; - align-items: center; - justify-content: center; - overflow: hidden; - border: 1px solid var(--border); - border-radius: 50%; - background: var(--bg-card-strong); - color: var(--text-secondary); + display: inline-flex; + width: 36px; + height: 36px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--bg-card-strong); + color: var(--text-secondary); } .ownerAvatar img { - width: 100%; - height: 100%; - object-fit: cover; + width: 100%; + height: 100%; + object-fit: cover; } .identityText { - min-width: 0; + min-width: 0; } .name { - overflow: hidden; - color: var(--text-primary); - font-weight: 650; - text-overflow: ellipsis; - white-space: nowrap; + overflow: hidden; + color: var(--text-primary); + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; } .meta { - color: var(--text-tertiary); - font-size: var(--admin-font-size); + color: var(--text-tertiary); + font-size: var(--admin-font-size); +} + +.sortHeader { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 6px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.sortHeader:hover, +.sortHeaderActive { + color: var(--primary); +} + +.sortHeader:focus-visible { + border-radius: var(--radius-sm); + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +.sortArrow { + color: currentColor; + font-size: 12px; + line-height: 1; } .stack { - display: grid; - gap: 2px; + display: grid; + gap: 2px; } .primaryText { - color: var(--text-primary); - font-weight: 650; + color: var(--text-primary); + font-weight: 650; } .rowActions { - display: inline-flex; - align-items: center; - gap: var(--space-2); + display: inline-flex; + align-items: center; + gap: var(--space-2); } .statusButton { - display: inline-flex; - align-items: center; - padding: 0; - border: 0; - background: transparent; - cursor: pointer; + display: inline-flex; + align-items: center; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; } .statusButton:focus-visible { - border-radius: 999px; - outline: 2px solid var(--primary); - outline-offset: 2px; + border-radius: 999px; + outline: 2px solid var(--primary); + outline-offset: 2px; } .statusButton:hover :global(.status-badge) { - border-color: var(--primary-border); + border-color: var(--primary-border); } .detailHero { - display: flex; - min-width: 0; - align-items: center; - gap: var(--space-4); - padding-bottom: var(--space-4); - border-bottom: 1px solid var(--border); + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-4); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--border); } .detailCover { - display: inline-flex; - width: 72px; - height: 72px; - flex: 0 0 auto; - align-items: center; - justify-content: center; - overflow: hidden; - border: 1px solid var(--border); - border-radius: var(--radius-card); - background: var(--bg-card-strong); - color: var(--text-secondary); + display: inline-flex; + width: 72px; + height: 72px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card-strong); + color: var(--text-secondary); } .detailCover img { - width: 100%; - height: 100%; - object-fit: cover; + width: 100%; + height: 100%; + object-fit: cover; } .detailHeroText { - display: grid; - min-width: 0; - gap: var(--space-2); + display: grid; + min-width: 0; + gap: var(--space-2); } .detailTitleRow { - display: flex; - min-width: 0; - align-items: center; - flex-wrap: wrap; - gap: var(--space-2); + display: flex; + min-width: 0; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); } .detailTitleRow h3 { - overflow: hidden; - margin: 0; - color: var(--text-primary); - font-size: 18px; - font-weight: 800; - text-overflow: ellipsis; - white-space: nowrap; + overflow: hidden; + margin: 0; + color: var(--text-primary); + font-size: 18px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; } .detailMeta { - color: var(--text-tertiary); - font-size: var(--admin-font-size); + color: var(--text-tertiary); + font-size: var(--admin-font-size); +} + +.copyValue { + display: inline-flex; + max-width: 100%; + min-width: 0; + align-items: center; + gap: 6px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-weight: 650; + text-align: left; + cursor: pointer; +} + +.copyValue span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.copyValue:hover { + color: var(--primary); +} + +.copyValue:disabled { + cursor: default; +} + +.copyValue:focus-visible { + border-radius: var(--radius-sm); + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +.copyIcon { + flex: 0 0 auto; + color: currentColor; +} + +.copyState { + flex: 0 0 auto; + color: var(--success); + font-size: 12px; + font-weight: 700; } .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); + 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; + 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); + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); } .detailItem { - display: grid; - min-width: 0; - gap: 4px; + display: grid; + min-width: 0; + gap: 4px; } .detailLabel { - color: var(--text-tertiary); - font-size: 12px; + color: var(--text-tertiary); + font-size: 12px; } .detailValue { - overflow: hidden; - color: var(--text-primary); - font-size: var(--admin-font-size); - font-weight: 650; - text-overflow: ellipsis; - white-space: nowrap; + overflow: hidden; + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; } .detailOwner { - display: flex; - min-width: 0; - align-items: center; - gap: var(--space-3); + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-3); } .detailOwnerAvatar { - display: inline-flex; - width: 40px; - height: 40px; - flex: 0 0 auto; - align-items: center; - justify-content: center; - overflow: hidden; - border: 1px solid var(--border); - border-radius: 50%; - background: var(--bg-card); - color: var(--text-secondary); + display: inline-flex; + width: 40px; + height: 40px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--bg-card); + color: var(--text-secondary); } .detailOwnerAvatar img { - width: 100%; - height: 100%; - object-fit: cover; + width: 100%; + height: 100%; + object-fit: cover; } .detailOwnerText { - display: grid; - min-width: 0; - gap: 2px; + display: grid; + min-width: 0; + gap: 2px; } .detailOwnerText strong, .detailOwnerText span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .detailOwnerText strong { - color: var(--text-primary); - font-size: var(--admin-font-size); + color: var(--text-primary); + font-size: var(--admin-font-size); } .detailOwnerText span { - color: var(--text-tertiary); - font-size: 12px; + color: var(--text-tertiary); + font-size: 12px; +} + +.configForm { + padding: var(--space-5); +} + +.configSection { + display: grid; + max-width: 720px; + gap: var(--space-4); +} + +.configHeader { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-3); +} + +.configIcon { + display: inline-flex; + width: 40px; + height: 40px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong); + color: var(--text-secondary); +} + +.configTitle { + display: grid; + min-width: 0; + gap: 2px; +} + +.configTitle h2 { + margin: 0; + color: var(--text-primary); + font-size: 18px; + font-weight: 800; +} + +.configTitle span { + color: var(--text-tertiary); + font-size: var(--admin-font-size); +} + +.seatOptions { + display: grid; + grid-template-columns: repeat(5, minmax(92px, 1fr)); + gap: var(--space-2); +} + +.seatOptions :global(.MuiFormControlLabel-root) { + min-height: 40px; + margin: 0; + padding: 0 var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card); } @media (max-width: 760px) { - .toolbar, - .filters { - align-items: stretch; - flex-direction: column; - } + .toolbar, + .filters { + align-items: stretch; + flex-direction: column; + } - .search, - .statusSelect, - .regionInput { - flex: 1 1 auto; - width: 100%; - } + .search, + .statusSelect, + .regionInput { + flex: 1 1 auto; + width: 100%; + } - .detailGrid { - grid-template-columns: 1fr; - } + .detailGrid { + grid-template-columns: 1fr; + } + + .configForm { + padding: var(--space-4); + } + + .seatOptions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } diff --git a/src/features/rooms/routes.js b/src/features/rooms/routes.js index 64cd24f..757b6ad 100644 --- a/src/features/rooms/routes.js +++ b/src/features/rooms/routes.js @@ -1,12 +1,20 @@ import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; export const roomRoutes = [ - { - label: "房间列表", - loader: () => import("./pages/RoomListPage.jsx").then((module) => module.RoomListPage), - menuCode: MENU_CODES.roomList, - pageKey: "room-list", - path: "/rooms", - permission: PERMISSIONS.roomView - } + { + label: "房间列表", + loader: () => import("./pages/RoomListPage.jsx").then((module) => module.RoomListPage), + menuCode: MENU_CODES.roomList, + pageKey: "room-list", + path: "/rooms", + permission: PERMISSIONS.roomView, + }, + { + label: "房间配置", + loader: () => import("./pages/RoomConfigPage.jsx").then((module) => module.RoomConfigPage), + menuCode: MENU_CODES.roomConfig, + pageKey: "room-config", + path: "/rooms/config", + permission: PERMISSIONS.roomConfigView, + }, ]; diff --git a/src/features/rooms/schema.ts b/src/features/rooms/schema.ts index 7da664a..016dfc5 100644 --- a/src/features/rooms/schema.ts +++ b/src/features/rooms/schema.ts @@ -1,17 +1,40 @@ import { z } from "zod"; const optionalText = (max: number, message: string) => z.string().trim().max(max, message).optional().default(""); +const roomSeatCandidates = [10, 15, 20, 25, 30] as const; export const roomUpdateSchema = z.object({ - coverUrl: optionalText(512, "房间头像不能超过 512 个字符"), - description: optionalText(512, "房间简介不能超过 512 个字符"), - title: z.string().trim().min(1, "请输入房间名称").max(128, "房间名称不能超过 128 个字符"), - visibleRegionId: z.coerce.number().int().min(0, "区域 ID 不正确").default(0) + coverUrl: optionalText(512, "房间头像不能超过 512 个字符"), + description: optionalText(512, "房间简介不能超过 512 个字符"), + title: z.string().trim().min(1, "请输入房间名称").max(128, "房间名称不能超过 128 个字符"), + visibleRegionId: z.coerce.number().int().min(0, "区域 ID 不正确").default(0), }); export const roomStatusSchema = z.object({ - status: z.string().trim().refine((value) => value === "active" || value === "closed", "房间状态不正确") + status: z + .string() + .trim() + .refine((value) => value === "active" || value === "closed", "房间状态不正确"), }); +export const roomConfigSchema = z + .object({ + allowedSeatCounts: z + .array(z.coerce.number().int()) + .min(1, "至少启用一个座位数") + .refine( + (values) => + values.every((value) => roomSeatCandidates.includes(value as (typeof roomSeatCandidates)[number])), + "座位数只能选择 10、15、20、25、30", + ) + .refine((values) => new Set(values).size === values.length, "座位数不能重复"), + defaultSeatCount: z.coerce.number().int(), + }) + .refine((value) => value.allowedSeatCounts.includes(value.defaultSeatCount), { + message: "默认座位数必须在已启用座位数中", + path: ["defaultSeatCount"], + }); + export type RoomUpdateForm = z.infer; export type RoomStatusForm = z.infer; +export type RoomConfigForm = z.infer; diff --git a/src/features/users/components/UserFormDrawer.jsx b/src/features/users/components/UserFormDrawer.jsx index 22b6dd2..5731db7 100644 --- a/src/features/users/components/UserFormDrawer.jsx +++ b/src/features/users/components/UserFormDrawer.jsx @@ -1,6 +1,5 @@ import Drawer from "@mui/material/Drawer"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import Switch from "@mui/material/Switch"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import TextField from "@mui/material/TextField"; import { Button } from "@/shared/ui/Button.jsx"; @@ -18,35 +17,48 @@ export function UserFormDrawer({

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

- setForm({ ...form, username: event.target.value })} - /> - setForm({ ...form, name: event.target.value })} /> - setForm({ ...form, team: event.target.value })} /> - {!editingUser ? ( - setForm({ ...form, password: event.target.value })} /> - ) : null} -
角色
-
- {roles.map((role) => ( - - ))} -
- setForm({ ...form, mfaEnabled: event.target.checked })} />} - label={form.mfaEnabled ? "MFA 已开启" : "MFA 未开启"} - /> +
+
基础信息
+
+ setForm({ ...form, username: event.target.value })} + /> + setForm({ ...form, name: event.target.value })} /> + setForm({ ...form, team: event.target.value })} /> + {!editingUser ? ( + setForm({ ...form, password: event.target.value })} /> + ) : null} +
+
+
+
角色
+
+ {roles.map((role) => ( + + ))} +
+
+
+
安全设置
+ setForm({ ...form, mfaEnabled: event.target.checked })} + /> +
diff --git a/src/features/users/components/UserTable.jsx b/src/features/users/components/UserTable.jsx index 704bd05..6821183 100644 --- a/src/features/users/components/UserTable.jsx +++ b/src/features/users/components/UserTable.jsx @@ -23,6 +23,7 @@ export function UserTable({ onStatusFilterChange, onToggleDisabled, onToggleLocked, + pagination, query, roleFilter, roleFilters = [], @@ -135,6 +136,7 @@ export function UserTable({ columns={columns} items={users} minWidth="1180px" + pagination={pagination} rowKey={(user) => user.id} /> ); diff --git a/src/features/users/pages/UserManagementPage.jsx b/src/features/users/pages/UserManagementPage.jsx index 1290734..a14b7c6 100644 --- a/src/features/users/pages/UserManagementPage.jsx +++ b/src/features/users/pages/UserManagementPage.jsx @@ -4,10 +4,8 @@ import PersonRemoveOutlined from "@mui/icons-material/PersonRemoveOutlined"; import ShieldOutlined from "@mui/icons-material/ShieldOutlined"; import VerifiedUserOutlined from "@mui/icons-material/VerifiedUserOutlined"; import { DataState } from "@/shared/ui/DataState.jsx"; -import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { AdminActionIconButton, - AdminFilterResetButton, AdminListBody, AdminListPage, AdminListToolbar, @@ -25,12 +23,6 @@ export function UserManagementPage() { <> - } actions={ <> {page.abilities.canViewRoles ? ( @@ -87,14 +79,14 @@ export function UserManagementPage() { onQueryChange={page.changeQuery} onRoleFilterChange={page.changeRoleFilter} onStatusFilterChange={page.changeStatusFilter} + pagination={{ + page: page.page, + pageSize: page.users.pageSize || 10, + total: page.users.total || 0, + onPageChange: page.setPage, + }} users={page.users.items || []} /> - diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 8b7b0b8..c6b986b 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -15,6 +15,7 @@ export interface ApiEndpoint { export const API_OPERATIONS = { appBanUser: "appBanUser", appGetUser: "appGetUser", + appListBannedUsers: "appListBannedUsers", appListLoginLogs: "appListLoginLogs", appListUserLoginLogs: "appListUserLoginLogs", appListUsers: "appListUsers", @@ -26,14 +27,18 @@ export const API_OPERATIONS = { changePassword: "changePassword", closeAgency: "closeAgency", createAgency: "createAgency", + createAppVersion: "createAppVersion", createBanner: "createBanner", createBD: "createBD", createBDLeader: "createBDLeader", + createCatalog: "createCatalog", createCoinSeller: "createCoinSeller", createCountry: "createCountry", createGift: "createGift", createMenu: "createMenu", createPermission: "createPermission", + createPlatform: "createPlatform", + createRechargeProduct: "createRechargeProduct", createRegion: "createRegion", createResource: "createResource", createResourceGroup: "createResourceGroup", @@ -43,12 +48,13 @@ export const API_OPERATIONS = { createUserExportJob: "createUserExportJob", creditCoinSellerStock: "creditCoinSellerStock", dashboardOverview: "dashboardOverview", + deleteAppVersion: "deleteAppVersion", deleteBanner: "deleteBanner", deleteBDLeader: "deleteBDLeader", deleteCountry: "deleteCountry", deleteMenu: "deleteMenu", - deleteNotification: "deleteNotification", deletePermission: "deletePermission", + deleteRechargeProduct: "deleteRechargeProduct", deleteRole: "deleteRole", deleteRoom: "deleteRoom", disableCountry: "disableCountry", @@ -67,17 +73,22 @@ export const API_OPERATIONS = { exportUsers: "exportUsers", getCountry: "getCountry", getRegion: "getRegion", + getRegistrationRewardConfig: "getRegistrationRewardConfig", getResource: "getResource", getResourceGroup: "getResourceGroup", getRoleDataScopes: "getRoleDataScopes", + getRoomConfig: "getRoomConfig", getUser: "getUser", grantResource: "grantResource", grantResourceGroup: "grantResourceGroup", listAgencies: "listAgencies", listApps: "listApps", + listAppVersions: "listAppVersions", listBanners: "listBanners", listBDLeaders: "listBDLeaders", listBDs: "listBDs", + listCatalog: "listCatalog", + listCoinLedger: "listCoinLedger", listCoinSellers: "listCoinSellers", listCountries: "listCountries", listGifts: "listGifts", @@ -85,11 +96,13 @@ export const API_OPERATIONS = { listHosts: "listHosts", listJobs: "listJobs", listLoginLogs: "listLoginLogs", - listNotifications: "listNotifications", listOperationLogs: "listOperationLogs", listPermissions: "listPermissions", + listPlatforms: "listPlatforms", listRechargeBills: "listRechargeBills", + listRechargeProducts: "listRechargeProducts", listRegions: "listRegions", + listRegistrationRewardClaims: "listRegistrationRewardClaims", listResourceGrants: "listResourceGrants", listResourceGroups: "listResourceGroups", listResources: "listResources", @@ -100,8 +113,6 @@ export const API_OPERATIONS = { listUsers: "listUsers", login: "login", logout: "logout", - markAllNotificationsRead: "markAllNotificationsRead", - markNotificationRead: "markNotificationRead", me: "me", navigationMenus: "navigationMenus", refresh: "refresh", @@ -114,22 +125,29 @@ export const API_OPERATIONS = { setBDLeaderStatus: "setBDLeaderStatus", setBDStatus: "setBDStatus", setCoinSellerStatus: "setCoinSellerStatus", + setGameStatus: "setGameStatus", setTaskDefinitionStatus: "setTaskDefinitionStatus", sortMenus: "sortMenus", syncPermissions: "syncPermissions", + updateAppVersion: "updateAppVersion", updateBanner: "updateBanner", + updateCatalog: "updateCatalog", updateCountry: "updateCountry", updateGift: "updateGift", updateH5Links: "updateH5Links", updateMenu: "updateMenu", updateMenuVisible: "updateMenuVisible", updatePermission: "updatePermission", + updatePlatform: "updatePlatform", + updateRechargeProduct: "updateRechargeProduct", updateRegion: "updateRegion", + updateRegistrationRewardConfig: "updateRegistrationRewardConfig", updateResource: "updateResource", updateResourceGroup: "updateResourceGroup", updateResourceGroupItems: "updateResourceGroupItems", updateRole: "updateRole", updateRoom: "updateRoom", + updateRoomConfig: "updateRoomConfig", updateTaskDefinition: "updateTaskDefinition", updateUser: "updateUser", updateUserStatus: "updateUserStatus", @@ -154,6 +172,13 @@ export const API_ENDPOINTS: Record = { permission: "app-user:view", permissions: ["app-user:view"] }, + appListBannedUsers: { + method: "GET", + operationId: API_OPERATIONS.appListBannedUsers, + path: "/v1/app/users/bans", + permission: "app-user:view", + permissions: ["app-user:view"] + }, appListLoginLogs: { method: "GET", operationId: API_OPERATIONS.appListLoginLogs, @@ -229,6 +254,13 @@ export const API_ENDPOINTS: Record = { permission: "agency:create", permissions: ["agency:create"] }, + createAppVersion: { + method: "POST", + operationId: API_OPERATIONS.createAppVersion, + path: "/v1/admin/app-config/versions", + permission: "app-version:create", + permissions: ["app-version:create"] + }, createBanner: { method: "POST", operationId: API_OPERATIONS.createBanner, @@ -250,6 +282,13 @@ export const API_ENDPOINTS: Record = { permission: "bd:create", permissions: ["bd:create"] }, + createCatalog: { + method: "POST", + operationId: API_OPERATIONS.createCatalog, + path: "/v1/admin/game/games", + permission: "game:create", + permissions: ["game:create"] + }, createCoinSeller: { method: "POST", operationId: API_OPERATIONS.createCoinSeller, @@ -285,6 +324,20 @@ export const API_ENDPOINTS: Record = { permission: "permission:create", permissions: ["permission:create"] }, + createPlatform: { + method: "POST", + operationId: API_OPERATIONS.createPlatform, + path: "/v1/admin/game/platforms", + permission: "game:update", + permissions: ["game:update"] + }, + createRechargeProduct: { + method: "POST", + operationId: API_OPERATIONS.createRechargeProduct, + path: "/v1/admin/payment/recharge-products", + permission: "payment-product:create", + permissions: ["payment-product:create"] + }, createRegion: { method: "POST", operationId: API_OPERATIONS.createRegion, @@ -348,6 +401,13 @@ export const API_ENDPOINTS: Record = { permission: "overview:view", permissions: ["overview:view"] }, + deleteAppVersion: { + method: "DELETE", + operationId: API_OPERATIONS.deleteAppVersion, + path: "/v1/admin/app-config/versions/{version_id}", + permission: "app-version:delete", + permissions: ["app-version:delete"] + }, deleteBanner: { method: "DELETE", operationId: API_OPERATIONS.deleteBanner, @@ -376,13 +436,6 @@ export const API_ENDPOINTS: Record = { permission: "menu:delete", permissions: ["menu:delete"] }, - deleteNotification: { - method: "DELETE", - operationId: API_OPERATIONS.deleteNotification, - path: "/v1/notifications/{id}", - permission: "notification:delete", - permissions: ["notification:delete"] - }, deletePermission: { method: "DELETE", operationId: API_OPERATIONS.deletePermission, @@ -390,6 +443,13 @@ export const API_ENDPOINTS: Record = { permission: "permission:delete", permissions: ["permission:delete"] }, + deleteRechargeProduct: { + method: "DELETE", + operationId: API_OPERATIONS.deleteRechargeProduct, + path: "/v1/admin/payment/recharge-products/{product_id}", + permission: "payment-product:delete", + permissions: ["payment-product:delete"] + }, deleteRole: { method: "DELETE", operationId: API_OPERATIONS.deleteRole, @@ -516,6 +576,13 @@ export const API_ENDPOINTS: Record = { permission: "region:view", permissions: ["region:view"] }, + getRegistrationRewardConfig: { + method: "GET", + operationId: API_OPERATIONS.getRegistrationRewardConfig, + path: "/v1/admin/activity/registration-reward/config", + permission: "registration-reward:view", + permissions: ["registration-reward:view"] + }, getResource: { method: "GET", operationId: API_OPERATIONS.getResource, @@ -537,6 +604,13 @@ export const API_ENDPOINTS: Record = { permission: "role:data-scope", permissions: ["role:data-scope"] }, + getRoomConfig: { + method: "GET", + operationId: API_OPERATIONS.getRoomConfig, + path: "/v1/admin/rooms/config", + permission: "room-config:view", + permissions: ["room-config:view"] + }, getUser: { method: "GET", operationId: API_OPERATIONS.getUser, @@ -570,6 +644,13 @@ export const API_ENDPOINTS: Record = { operationId: API_OPERATIONS.listApps, path: "/v1/admin/apps" }, + listAppVersions: { + method: "GET", + operationId: API_OPERATIONS.listAppVersions, + path: "/v1/admin/app-config/versions", + permission: "app-version:view", + permissions: ["app-version:view"] + }, listBanners: { method: "GET", operationId: API_OPERATIONS.listBanners, @@ -591,6 +672,20 @@ export const API_ENDPOINTS: Record = { permission: "bd:view", permissions: ["bd:view"] }, + listCatalog: { + method: "GET", + operationId: API_OPERATIONS.listCatalog, + path: "/v1/admin/game/games", + permission: "game:view", + permissions: ["game:view"] + }, + listCoinLedger: { + method: "GET", + operationId: API_OPERATIONS.listCoinLedger, + path: "/v1/admin/operations/coin-ledger", + permission: "coin-ledger:view", + permissions: ["coin-ledger:view"] + }, listCoinSellers: { method: "GET", operationId: API_OPERATIONS.listCoinSellers, @@ -640,13 +735,6 @@ export const API_ENDPOINTS: Record = { permission: "log:view", permissions: ["log:view"] }, - listNotifications: { - method: "GET", - operationId: API_OPERATIONS.listNotifications, - path: "/v1/notifications", - permission: "notification:view", - permissions: ["notification:view"] - }, listOperationLogs: { method: "GET", operationId: API_OPERATIONS.listOperationLogs, @@ -661,6 +749,13 @@ export const API_ENDPOINTS: Record = { permission: "permission:view", permissions: ["permission:view","role:permission","role:manage"] }, + listPlatforms: { + method: "GET", + operationId: API_OPERATIONS.listPlatforms, + path: "/v1/admin/game/platforms", + permission: "game:view", + permissions: ["game:view"] + }, listRechargeBills: { method: "GET", operationId: API_OPERATIONS.listRechargeBills, @@ -668,6 +763,13 @@ export const API_ENDPOINTS: Record = { permission: "payment-bill:view", permissions: ["payment-bill:view"] }, + listRechargeProducts: { + method: "GET", + operationId: API_OPERATIONS.listRechargeProducts, + path: "/v1/admin/payment/recharge-products", + permission: "payment-product:view", + permissions: ["payment-product:view"] + }, listRegions: { method: "GET", operationId: API_OPERATIONS.listRegions, @@ -675,6 +777,13 @@ export const API_ENDPOINTS: Record = { permission: "region:view", permissions: ["region:view"] }, + listRegistrationRewardClaims: { + method: "GET", + operationId: API_OPERATIONS.listRegistrationRewardClaims, + path: "/v1/admin/activity/registration-reward/claims", + permission: "registration-reward:view", + permissions: ["registration-reward:view"] + }, listResourceGrants: { method: "GET", operationId: API_OPERATIONS.listResourceGrants, @@ -741,20 +850,6 @@ export const API_ENDPOINTS: Record = { operationId: API_OPERATIONS.logout, path: "/v1/auth/logout" }, - markAllNotificationsRead: { - method: "PATCH", - operationId: API_OPERATIONS.markAllNotificationsRead, - path: "/v1/notifications/read-all", - permission: "notification:read-all", - permissions: ["notification:read-all"] - }, - markNotificationRead: { - method: "PATCH", - operationId: API_OPERATIONS.markNotificationRead, - path: "/v1/notifications/{id}/read", - permission: "notification:read", - permissions: ["notification:read"] - }, me: { method: "GET", operationId: API_OPERATIONS.me, @@ -831,6 +926,13 @@ export const API_ENDPOINTS: Record = { permission: "coin-seller:update", permissions: ["coin-seller:update"] }, + setGameStatus: { + method: "PATCH", + operationId: API_OPERATIONS.setGameStatus, + path: "/v1/admin/game/games/{game_id}/status", + permission: "game:status", + permissions: ["game:status"] + }, setTaskDefinitionStatus: { method: "POST", operationId: API_OPERATIONS.setTaskDefinitionStatus, @@ -852,6 +954,13 @@ export const API_ENDPOINTS: Record = { permission: "permission:sync", permissions: ["permission:sync"] }, + updateAppVersion: { + method: "PUT", + operationId: API_OPERATIONS.updateAppVersion, + path: "/v1/admin/app-config/versions/{version_id}", + permission: "app-version:update", + permissions: ["app-version:update"] + }, updateBanner: { method: "PUT", operationId: API_OPERATIONS.updateBanner, @@ -859,6 +968,13 @@ export const API_ENDPOINTS: Record = { permission: "app-config:update", permissions: ["app-config:update"] }, + updateCatalog: { + method: "PATCH", + operationId: API_OPERATIONS.updateCatalog, + path: "/v1/admin/game/games/{game_id}", + permission: "game:update", + permissions: ["game:update"] + }, updateCountry: { method: "PATCH", operationId: API_OPERATIONS.updateCountry, @@ -901,6 +1017,20 @@ export const API_ENDPOINTS: Record = { permission: "permission:update", permissions: ["permission:update"] }, + updatePlatform: { + method: "PATCH", + operationId: API_OPERATIONS.updatePlatform, + path: "/v1/admin/game/platforms/{platform_code}", + permission: "game:update", + permissions: ["game:update"] + }, + updateRechargeProduct: { + method: "PUT", + operationId: API_OPERATIONS.updateRechargeProduct, + path: "/v1/admin/payment/recharge-products/{product_id}", + permission: "payment-product:update", + permissions: ["payment-product:update"] + }, updateRegion: { method: "PATCH", operationId: API_OPERATIONS.updateRegion, @@ -908,6 +1038,13 @@ export const API_ENDPOINTS: Record = { permission: "region:update", permissions: ["region:update"] }, + updateRegistrationRewardConfig: { + method: "PUT", + operationId: API_OPERATIONS.updateRegistrationRewardConfig, + path: "/v1/admin/activity/registration-reward/config", + permission: "registration-reward:update", + permissions: ["registration-reward:update"] + }, updateResource: { method: "PUT", operationId: API_OPERATIONS.updateResource, @@ -943,6 +1080,13 @@ export const API_ENDPOINTS: Record = { permission: "room:update", permissions: ["room:update"] }, + updateRoomConfig: { + method: "PUT", + operationId: API_OPERATIONS.updateRoomConfig, + path: "/v1/admin/rooms/config", + permission: "room-config:update", + permissions: ["room-config:update"] + }, updateTaskDefinition: { method: "PUT", operationId: API_OPERATIONS.updateTaskDefinition, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 995773d..b228bdb 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -52,6 +52,38 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/activity/registration-reward/claims": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listRegistrationRewardClaims"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/activity/registration-reward/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getRegistrationRewardConfig"]; + put: operations["updateRegistrationRewardConfig"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/agencies": { parameters: { query?: never; @@ -148,6 +180,38 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/app-config/versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listAppVersions"]; + put?: never; + post: operations["createAppVersion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/app-config/versions/{version_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateAppVersion"]; + post?: never; + delete: operations["deleteAppVersion"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/apps": { parameters: { query?: never; @@ -388,6 +452,86 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/game/games": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listCatalog"]; + put?: never; + post: operations["createCatalog"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/game/games/{game_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["updateCatalog"]; + trace?: never; + }; + "/admin/game/games/{game_id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["setGameStatus"]; + trace?: never; + }; + "/admin/game/platforms": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPlatforms"]; + put?: never; + post: operations["createPlatform"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/game/platforms/{platform_code}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["updatePlatform"]; + trace?: never; + }; "/admin/gifts": { parameters: { query?: never; @@ -468,6 +612,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/operations/coin-ledger": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listCoinLedger"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/payment/recharge-bills": { parameters: { query?: never; @@ -484,6 +644,38 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/payment/recharge-products": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listRechargeProducts"]; + put?: never; + post: operations["createRechargeProduct"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/payment/recharge-products/{product_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateRechargeProduct"]; + post?: never; + delete: operations["deleteRechargeProduct"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/regions": { parameters: { query?: never; @@ -772,6 +964,22 @@ export interface paths { patch: operations["updateRoom"]; trace?: never; }; + "/admin/rooms/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getRoomConfig"]; + put: operations["updateRoomConfig"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/app/users": { parameters: { query?: never; @@ -868,6 +1076,22 @@ export interface paths { patch?: never; trace?: never; }; + "/app/users/bans": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["appListBannedUsers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/app/users/login-logs": { parameters: { query?: never; @@ -1124,70 +1348,6 @@ export interface paths { patch?: never; trace?: never; }; - "/notifications": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["listNotifications"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/notifications/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete: operations["deleteNotification"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/notifications/{id}/read": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch: operations["markNotificationRead"]; - trace?: never; - }; - "/notifications/read-all": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch: operations["markAllNotificationsRead"]; - trace?: never; - }; "/permissions": { parameters: { query?: never; @@ -1535,12 +1695,6 @@ export interface components { ApiResponseMenuList: components["schemas"]["Envelope"] & { data?: components["schemas"]["Menu"][]; }; - ApiResponseNotification: components["schemas"]["Envelope"] & { - data?: components["schemas"]["Notification"]; - }; - ApiResponseNotificationList: components["schemas"]["Envelope"] & { - data?: components["schemas"]["NotificationList"]; - }; ApiResponsePermission: components["schemas"]["Envelope"] & { data?: components["schemas"]["Permission"]; }; @@ -1634,13 +1788,6 @@ export interface components { MenuInput: { [key: string]: unknown; }; - Notification: { - [key: string]: unknown; - }; - NotificationList: { - items: components["schemas"]["Notification"][]; - unread: number; - }; Permission: { [key: string]: unknown; }; @@ -1749,24 +1896,6 @@ export interface components { }; }; /** @description OK */ - NotificationListResponse: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ApiResponseNotificationList"]; - }; - }; - /** @description OK */ - NotificationResponse: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ApiResponseNotification"]; - }; - }; - /** @description OK */ PermissionListResponse: { headers: { [name: string]: unknown; @@ -1993,6 +2122,42 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + listRegistrationRewardClaims: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + getRegistrationRewardConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateRegistrationRewardConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; listAgencies: { parameters: { query?: { @@ -2128,6 +2293,58 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + listAppVersions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + createAppVersion: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateAppVersion: { + parameters: { + query?: never; + header?: never; + path: { + version_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + deleteAppVersion: { + parameters: { + query?: never; + header?: never; + path: { + version_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; listApps: { parameters: { query?: never; @@ -2413,6 +2630,96 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + listCatalog: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + createCatalog: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateCatalog: { + parameters: { + query?: never; + header?: never; + path: { + game_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + setGameStatus: { + parameters: { + query?: never; + header?: never; + path: { + game_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listPlatforms: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + createPlatform: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updatePlatform: { + parameters: { + query?: never; + header?: never; + path: { + platform_code: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; listGifts: { parameters: { query?: never; @@ -2498,6 +2805,18 @@ export interface operations { 200: components["responses"]["HostProfilePageResponse"]; }; }; + listCoinLedger: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; listRechargeBills: { parameters: { query?: never; @@ -2510,6 +2829,58 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + listRechargeProducts: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + createRechargeProduct: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateRechargeProduct: { + parameters: { + query?: never; + header?: never; + path: { + product_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + deleteRechargeProduct: { + parameters: { + query?: never; + header?: never; + path: { + product_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; listRegions: { parameters: { query?: never; @@ -2854,6 +3225,30 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + getRoomConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + updateRoomConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; appListUsers: { parameters: { query?: never; @@ -2950,6 +3345,18 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + appListBannedUsers: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; appListLoginLogs: { parameters: { query?: never; @@ -3166,58 +3573,6 @@ export interface operations { 200: components["responses"]["MenuListResponse"]; }; }; - listNotifications: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["NotificationListResponse"]; - }; - }; - deleteNotification: { - parameters: { - query?: never; - header?: never; - path: { - id: components["parameters"]["Id"]; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["EmptyResponse"]; - }; - }; - markNotificationRead: { - parameters: { - query?: never; - header?: never; - path: { - id: components["parameters"]["Id"]; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["NotificationResponse"]; - }; - }; - markAllNotificationsRead: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["NotificationListResponse"]; - }; - }; listPermissions: { parameters: { query?: never; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index dcc53f4..1d19888 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -132,7 +132,6 @@ export interface MenuSortItem { } export interface DashboardOverviewDto { - alerts?: Array<{ desc: string; name: string; time: string; type: string }>; activeUsers: number; disabledUsers: number; lockedUsers: number; @@ -140,11 +139,9 @@ export interface DashboardOverviewDto { menuTotal: number; rolesTotal: number; series?: { - notifications?: number[]; operations?: number[]; users?: number[]; }; - unreadNotifications: number; updatedAt?: string; usersTotal: number; } @@ -161,20 +158,6 @@ export interface LogDto { username?: string; } -export interface NotificationDto { - content: string; - createdAt?: string; - id: number; - level: string; - readAt?: string | null; - title: string; -} - -export interface NotificationListDto { - items: NotificationDto[]; - unread: number; -} - export interface SessionDto { accessToken?: string; permissions?: string[]; @@ -195,6 +178,31 @@ export interface ChangePasswordPayload { oldPassword: string; } +export interface CoinLedgerUserDto { + avatar?: string; + displayUserId?: string; + userId: string; + username?: string; +} + +export interface CoinLedgerEntryDto { + amount: number; + availableAfter: number; + availableDelta: number; + bizType?: string; + commandId?: string; + counterpartyUserId?: string; + createdAtMs?: number; + direction: "income" | "expense" | string; + entryId: number; + externalRef?: string; + metadata?: Record; + roomId?: string; + transactionId: string; + user: CoinLedgerUserDto; + userId: string; +} + export interface RechargeBillDto { appCode?: string; coinAmount: number; @@ -216,6 +224,64 @@ export interface RechargeBillDto { userId: number; } +export interface RechargeProductDto { + amountMicro?: number; + amountMinor?: number; + amountUsdt: string; + amountUsdtMicro: number; + appCode?: string; + channel?: string; + coinAmount: number; + createdAtMs?: number; + createdByUserId?: number; + currencyCode?: string; + description?: string; + enabled: boolean; + platform: "android" | "ios" | string; + policyVersion?: string; + productCode?: string; + productId: number; + productName: string; + regionIds: number[]; + resourceAssetType?: string; + sortOrder?: number; + status?: "active" | "disabled" | string; + updatedAtMs?: number; + updatedByUserId?: number; +} + +export interface RechargeProductPayload { + amountUsdt: string; + coinAmount: number; + description?: string; + enabled: boolean; + platform: "android" | "ios"; + productName: string; + regionIds: number[]; +} + +export interface AppVersionDto { + appCode?: string; + buildNumber: number; + createdAtMs?: number; + description?: string; + downloadUrl: string; + forceUpdate: boolean; + id: number; + platform: "android" | "ios" | string; + updatedAtMs?: number; + version: string; +} + +export interface AppVersionPayload { + buildNumber: number; + description?: string; + downloadUrl: string; + forceUpdate: boolean; + platform: "android" | "ios"; + version: string; +} + export interface BDProfileDto { avatar?: string; createdAtMs?: number; @@ -331,6 +397,34 @@ export interface AppUserDto { username?: string; } +export interface AppUserBriefDto { + avatar?: string; + displayUserId?: string; + userId: string; + username?: string; +} + +export interface AppUserBanOperatorDto { + account?: string; + adminId?: string; + avatar?: string; + displayUserId?: string; + name?: string; + type: "admin" | "app_user" | "system" | string; + userId?: string; +} + +export interface AppUserBanRecordDto { + createdAtMs?: number; + id: number; + newStatus?: string; + oldStatus?: string; + operator?: AppUserBanOperatorDto; + reason?: string; + requestId?: string; + target: AppUserBriefDto; +} + export interface AppUserLoginLogDto { avatar?: string; blockReason?: string; @@ -370,7 +464,6 @@ export interface RoomDto { coverUrl?: string; createdAtMs?: number; heat?: number; - hostUserId?: string; mode?: string; occupiedSeatCount?: number; onlineCount?: number; @@ -378,6 +471,7 @@ export interface RoomDto { ownerUserId?: string; regionName?: string; roomId: string; + roomContribution?: number; seatCount?: number; sortScore?: number; status?: string; @@ -402,6 +496,18 @@ export interface RoomUpdatePayload { visibleRegionId?: number; } +export interface RoomConfigDto { + allowedSeatCounts: number[]; + candidateSeatCounts: number[]; + defaultSeatCount: number; + updatedAtMs?: number; +} + +export interface RoomConfigPayload { + allowedSeatCounts: number[]; + defaultSeatCount: number; +} + export interface UploadResultDto { content_type?: string; contentType?: string; diff --git a/src/shared/ui/AdminFormDialog.jsx b/src/shared/ui/AdminFormDialog.jsx index de325eb..678410a 100644 --- a/src/shared/ui/AdminFormDialog.jsx +++ b/src/shared/ui/AdminFormDialog.jsx @@ -3,7 +3,9 @@ import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; +import FormControlLabel from "@mui/material/FormControlLabel"; import InputAdornment from "@mui/material/InputAdornment"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import TextField from "@mui/material/TextField"; import { Button } from "@/shared/ui/Button.jsx"; import styles from "@/shared/ui/AdminFormDialog.module.css"; @@ -68,6 +70,42 @@ export function AdminFormFieldGrid({ children, columns }) { ); } +export function AdminFormSwitchField({ + checked, + checkedLabel, + className, + disabled = false, + label, + onChange, + switchProps, + uncheckedLabel +}) { + const hasInlineLabel = Boolean(checkedLabel || uncheckedLabel); + const control = ( + onChange(event.target.checked, event)} + /> + ); + + if (hasInlineLabel) { + return {control}; + } + + return ( + + ); +} + export function AdminFormReadOnlyField({ label, value }) { const displayValue = value === undefined || value === null || value === "" ? "-" : value; diff --git a/src/shared/ui/AdminFormDialog.module.css b/src/shared/ui/AdminFormDialog.module.css index 5732f1d..3b732ad 100644 --- a/src/shared/ui/AdminFormDialog.module.css +++ b/src/shared/ui/AdminFormDialog.module.css @@ -52,8 +52,8 @@ flex-wrap: wrap; overflow: auto; align-items: start; - gap: var(--space-3); - padding: var(--space-2) var(--admin-form-content-x) var(--space-4) !important; + gap: var(--space-4); + padding: var(--space-2) var(--admin-form-content-x) var(--space-5) !important; } .content > :global(.MuiTextField-root), @@ -75,6 +75,8 @@ .section { display: grid; + width: min(100%, var(--admin-form-wide-width, 100%)); + max-width: 100%; gap: var(--space-3); } @@ -101,7 +103,8 @@ .section > :global(.MuiTextField-root), .section > :global(.MuiFormControl-root), -.section > :global(.MuiAutocomplete-root) { +.section > :global(.MuiAutocomplete-root), +.section > :global(.MuiFormControlLabel-root) { width: 100%; } @@ -116,17 +119,27 @@ display: grid; grid-template-columns: var(--admin-form-grid-columns, repeat(auto-fit, minmax(180px, 1fr))); gap: var(--space-3); - width: min(100%, var(--admin-form-wide-width)); + width: min(100%, var(--admin-form-wide-width, 100%)); max-width: 100%; } .fieldGrid > :global(.MuiTextField-root), .fieldGrid > :global(.MuiFormControl-root), .fieldGrid > :global(.MuiAutocomplete-root), +.fieldGrid > :global(.MuiFormControlLabel-root), +.fieldGrid > .switchField, .fieldGrid > .readOnlyField { width: 100%; } +.switchField { + display: inline-flex; + min-height: var(--control-height); + align-items: center; + margin-right: 0; + margin-left: 0; +} + .readOnlyField { display: grid; min-width: 0; diff --git a/src/shared/ui/AdminListLayout.jsx b/src/shared/ui/AdminListLayout.jsx index f54e407..81ea943 100644 --- a/src/shared/ui/AdminListLayout.jsx +++ b/src/shared/ui/AdminListLayout.jsx @@ -15,11 +15,17 @@ export function AdminListPage({ children }) { } export function AdminListToolbar({ actions, filters }) { + if (!actions && !filters) { + return null; + } + return (
-
-
{filters}
-
+ {filters ? ( +
+
{filters}
+
+ ) : null} {actions ?
{actions}
: null}
); diff --git a/src/shared/ui/AdminListLayout.module.css b/src/shared/ui/AdminListLayout.module.css index daebaee..1220f91 100644 --- a/src/shared/ui/AdminListLayout.module.css +++ b/src/shared/ui/AdminListLayout.module.css @@ -58,6 +58,7 @@ flex: 0 0 auto; align-items: center; gap: var(--space-2); + margin-left: auto; } .filters { diff --git a/src/shared/ui/AdminSwitch.jsx b/src/shared/ui/AdminSwitch.jsx new file mode 100644 index 0000000..03bea8c --- /dev/null +++ b/src/shared/ui/AdminSwitch.jsx @@ -0,0 +1,87 @@ +import Switch from "@mui/material/Switch"; + +const baseSwitchSx = { + "--switch-width": "40px", + "--switch-height": "24px", + "--switch-thumb-size": "18px", + "--switch-padding": "3px", + "--switch-translate-x": "16px", + margin: 0 +}; + +function cssContent(value) { + return `"${String(value ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function labeledSwitchSx(checkedLabel, uncheckedLabel) { + const labelLength = Math.max(Array.from(String(checkedLabel || "")).length, Array.from(String(uncheckedLabel || "")).length); + const width = Math.max(56, Math.min(84, 34 + labelLength * 11)); + + return { + "--switch-width": `${width}px`, + "--switch-height": "26px", + "--switch-thumb-size": "20px", + "--switch-padding": "3px", + "--switch-translate-x": `${width - 26}px`, + "& .MuiSwitch-track": { + overflow: "hidden", + position: "relative", + "&::before, &::after": { + position: "absolute", + top: 0, + bottom: 0, + display: "flex", + alignItems: "center", + fontSize: 10.5, + fontWeight: 760, + lineHeight: 1, + pointerEvents: "none", + transition: "opacity 180ms cubic-bezier(0.2, 0, 0, 1)" + }, + "&::before": { + right: 26, + left: 7, + justifyContent: "flex-start", + color: "var(--active-contrast)", + content: cssContent(checkedLabel), + opacity: 0 + }, + "&::after": { + right: 7, + left: 26, + justifyContent: "flex-end", + color: "var(--text-tertiary)", + content: cssContent(uncheckedLabel), + opacity: 1 + } + }, + "& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track::before": { + opacity: 1 + }, + "& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track::after": { + opacity: 0 + } + }; +} + +export function AdminSwitch({ checkedLabel, inputProps, label, sx, uncheckedLabel, ...props }) { + const hasInlineLabel = Boolean(checkedLabel || uncheckedLabel); + const switchLabelSx = hasInlineLabel ? labeledSwitchSx(checkedLabel, uncheckedLabel) : null; + const mergedSx = Array.isArray(sx) + ? [baseSwitchSx, switchLabelSx, ...sx].filter(Boolean) + : [baseSwitchSx, switchLabelSx, sx].filter(Boolean); + const mergedInputProps = label + ? { + "aria-label": label, + ...inputProps + } + : inputProps; + + return ( + + ); +} diff --git a/src/shared/ui/DataTable.jsx b/src/shared/ui/DataTable.jsx index c5ed263..3936c89 100644 --- a/src/shared/ui/DataTable.jsx +++ b/src/shared/ui/DataTable.jsx @@ -1,10 +1,14 @@ +import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined"; import SearchOutlined from "@mui/icons-material/SearchOutlined"; import MenuItem from "@mui/material/MenuItem"; import MenuList from "@mui/material/MenuList"; import Popover from "@mui/material/Popover"; import TextField from "@mui/material/TextField"; -import { useEffect, useMemo, useState } from "react"; +import Tooltip from "@mui/material/Tooltip"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/shared/ui/Button.jsx"; +import { IconButton } from "@/shared/ui/IconButton.jsx"; +import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; const DEFAULT_COLUMN_WIDTH = "minmax(120px, 1fr)"; const DEFAULT_RESIZE_MIN_WIDTH = 72; @@ -20,6 +24,7 @@ export function DataTable({ items, minWidth = "980px", onColumnWidthsChange, + pagination, resizableColumns = true, rowKey, tableClassName = "", @@ -29,25 +34,62 @@ export function DataTable({ const [columnWidths, setColumnWidths] = useState({}); const [filterMenu, setFilterMenu] = useState({ anchorEl: null, columnKey: "" }); const [filterQuery, setFilterQuery] = useState(""); + const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false); const [resizeSession, setResizeSession] = useState(null); + const scrollRef = useRef(null); const safeItems = Array.isArray(items) ? items : []; const preparedColumns = useMemo( () => columns.map((column, index) => ({ ...column, - fixed: resolveColumnFixed(column, index, columns.length, fixedEdges), + fixed: resolveColumnFixed(column, index, columns.length, fixedEdges && hasHorizontalOverflow), width: columnWidths[column.key] || column.width || DEFAULT_COLUMN_WIDTH })), - [columnWidths, columns, fixedEdges] + [columnWidths, columns, fixedEdges, hasHorizontalOverflow] ); const gridTemplate = preparedColumns.map((column) => column.width).join(" "); const activeFilterColumn = preparedColumns.find((column) => column.key === filterMenu.columnKey && column.filter) || null; const activeFilter = activeFilterColumn?.filter || null; const activeFilterIsText = activeFilter?.type === "text"; + const filterColumns = preparedColumns.filter((column) => column.filter); + const hasColumnFilters = filterColumns.length > 0; + const hasActiveColumnFilters = filterColumns.some((column) => isFilterActive(column.filter)); const filterOptions = useMemo(() => filterVisibleOptions(activeFilter, filterQuery), [activeFilter, filterQuery]); + useEffect(() => { + const scrollNode = scrollRef.current; + if (!scrollNode) { + return undefined; + } + + const measureOverflow = () => { + const nextHasOverflow = scrollNode.scrollWidth > scrollNode.clientWidth + 1; + setHasHorizontalOverflow((current) => (current === nextHasOverflow ? current : nextHasOverflow)); + }; + + measureOverflow(); + window.addEventListener("resize", measureOverflow); + + if (typeof ResizeObserver === "undefined") { + return () => { + window.removeEventListener("resize", measureOverflow); + }; + } + + const observer = new ResizeObserver(measureOverflow); + observer.observe(scrollNode); + if (scrollNode.firstElementChild) { + observer.observe(scrollNode.firstElementChild); + } + + return () => { + observer.disconnect(); + window.removeEventListener("resize", measureOverflow); + }; + }, [gridTemplate, minWidth, safeItems.length]); + useEffect(() => { if (!resizeSession) { return undefined; @@ -135,10 +177,28 @@ export function DataTable({ }; const clearTextFilter = () => { - activeFilter?.onChange?.(""); + if (activeFilter?.onReset) { + activeFilter.onReset(); + } else { + activeFilter?.onChange?.(""); + } closeColumnFilter(); }; + const resetColumnFilters = () => { + closeColumnFilter(); + filterColumns.forEach((column) => { + if (!isFilterActive(column.filter)) { + return; + } + if (column.filter.onReset) { + column.filter.onReset(); + return; + } + column.filter.onChange?.(""); + }); + }; + return (
{title || total !== undefined ? ( @@ -147,30 +207,41 @@ export function DataTable({ {total !== undefined ?
共 {total} 条
: null}
) : null} -
+
- {preparedColumns.map((column) => ( -
- - {resizableColumns && column.resizable !== false ? ( -
+ ); + })}
{safeItems.length ? ( safeItems.map((item, index) => { @@ -194,6 +265,7 @@ export function DataTable({ )}
+ {pagination ? : null} setFilterQuery(event.target.value)} />
- + + +
+ {quickRanges.map(([quickLabel, durationMs]) => ( + + ))} +
+
+ activateField("start")} + /> + activateField("end")} + /> +
+
+ changeMonth(-1)}> + + + {monthTitle(viewDate)} + changeMonth(1)}> + + +
+
+ {weekLabels.map((weekday) => ( + + {weekday} + + ))} + {days.map((day) => { + const isOutside = day.getMonth() !== viewDate.getMonth(); + const isSelected = sameDay(day, selectedDate) && Boolean(draft[`${activeField}Ms`]); + return ( + + ); + })} +
+
+ changeTime("hour", event.target.value)} + > + {Array.from({ length: 24 }, (_, hour) => ( + + {pad2(hour)} + + ))} + + changeTime("minute", event.target.value)} + > + {Array.from({ length: 60 }, (_, minute) => ( + + {pad2(minute)} + + ))} + +
+ {error ?
{error}
: null} +
+ + +
+ +
+ + ); +} + +function RangeFieldButton({ active, label, onClick, value }) { + return ( + + ); +} + +export function normalizeRange(value) { + return { + endMs: normalizeMs(value?.endMs), + startMs: normalizeMs(value?.startMs), + }; +} + +export function timeRangeLabel(label, value) { + const range = normalizeRange(value); + if (!range.startMs && !range.endMs) { + return label; + } + return `${formatRangePart(range.startMs) || "不限"} - ${formatRangePart(range.endMs) || "不限"}`; +} + +export function datetimeTextToMs(value) { + const trimmed = String(value || "").trim(); + if (!trimmed) { + return ""; + } + const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/.exec(trimmed); + if (!match) { + return null; + } + const [, year, month, day, hour, minute, second = "00"] = match; + const date = new Date( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second), + 0, + ); + if ( + date.getFullYear() !== Number(year) || + date.getMonth() !== Number(month) - 1 || + date.getDate() !== Number(day) || + date.getHours() !== Number(hour) || + date.getMinutes() !== Number(minute) || + date.getSeconds() !== Number(second) + ) { + return null; + } + return date.getTime(); +} + +function dateForField(range, field, fallbackDate) { + const ms = field === "start" ? range.startMs : range.endMs; + return ms ? new Date(ms) : fallbackDate; +} + +function setRangeField(range, field, value) { + return field === "start" ? { ...range, startMs: value } : { ...range, endMs: value }; +} + +function calendarDays(viewDate) { + const firstDay = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1); + const mondayIndex = (firstDay.getDay() + 6) % 7; + const start = new Date(firstDay); + start.setDate(firstDay.getDate() - mondayIndex); + + return Array.from({ length: 42 }, (_, index) => { + const day = new Date(start); + day.setDate(start.getDate() + index); + return day; + }); +} + +function sameDay(left, right) { + return ( + left.getFullYear() === right.getFullYear() && + left.getMonth() === right.getMonth() && + left.getDate() === right.getDate() + ); +} + +function monthTitle(date) { + return `${date.getFullYear()}年${pad2(date.getMonth() + 1)}月`; +} + +function formatRangePart(value) { + const text = msToDatetimeText(value); + return text ? text.replace("-", "/").replace("-", "/") : ""; +} + +function msToDatetimeText(value) { + const ms = normalizeMs(value); + if (!ms) { + return ""; + } + const date = new Date(ms); + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`; +} + +function normalizeMs(value) { + if (value === "" || value === null || value === undefined) { + return ""; + } + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : ""; +} + +function pad2(value) { + return String(value).padStart(2, "0"); +} diff --git a/src/shared/ui/TimeRangeFilter.module.css b/src/shared/ui/TimeRangeFilter.module.css new file mode 100644 index 0000000..80e9d8e --- /dev/null +++ b/src/shared/ui/TimeRangeFilter.module.css @@ -0,0 +1,209 @@ +.trigger { + flex: 0 0 auto; + justify-content: flex-start !important; + width: auto; + min-width: 132px !important; + max-width: min(420px, 100%); + border-color: var(--border) !important; + background: var(--bg-input-strong) !important; + box-shadow: none !important; + color: var(--text-secondary) !important; + font-weight: 650 !important; + transform: none !important; +} + +.trigger:hover, +.trigger:focus-visible { + border-color: var(--primary-border-strong) !important; + background: var(--bg-card) !important; + box-shadow: 0 0 0 3px var(--focus-ring) !important; + color: var(--text-primary) !important; + transform: none !important; +} + +.trigger :global(.MuiButton-startIcon) { + margin-right: var(--space-2); + margin-left: 0; +} + +.trigger :global(.MuiButton-endIcon) { + margin-right: 0; + margin-left: var(--space-4); +} + +.triggerText { + flex: 0 1 auto; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.paper { + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-flyout); + box-shadow: var(--shadow-panel); +} + +.panel { + display: grid; + width: min(420px, calc(100vw - 32px)); + gap: var(--space-3); + padding: var(--space-3); +} + +.quickRanges { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2); +} + +.quickButton { + min-width: 0 !important; + padding-right: var(--space-2) !important; + padding-left: var(--space-2) !important; +} + +.rangeFields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); +} + +.rangeField { + display: grid; + min-width: 0; + min-height: 58px; + align-content: center; + gap: var(--space-1); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-input-strong); + color: var(--text-primary); + text-align: left; + transition: + border-color var(--motion-fast) var(--ease-standard), + box-shadow var(--motion-fast) var(--ease-standard), + background var(--motion-fast) var(--ease-standard); +} + +.rangeField:hover, +.rangeFieldActive { + border-color: var(--primary-border-active); + background: var(--bg-card); + box-shadow: 0 0 0 3px var(--focus-ring); +} + +.rangeFieldLabel, +.weekday { + color: var(--text-tertiary); + font-size: 12px; + line-height: 16px; +} + +.rangeFieldValue { + overflow: hidden; + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 700; + line-height: var(--admin-line-height); + text-overflow: ellipsis; + white-space: nowrap; +} + +.calendarHeader { + display: grid; + grid-template-columns: var(--control-height) 1fr var(--control-height); + align-items: center; + gap: var(--space-2); +} + +.calendarTitle { + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 760; + text-align: center; +} + +.calendarGrid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: var(--space-1); +} + +.weekday { + display: inline-flex; + height: 24px; + align-items: center; + justify-content: center; +} + +.dayButton { + display: inline-flex; + min-width: 0; + height: 34px; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: var(--radius-control); + background: transparent; + color: var(--text-primary); + font: inherit; + font-size: var(--admin-font-size); + cursor: pointer; + transition: + border-color var(--motion-fast) var(--ease-standard), + background var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard); +} + +.dayButton:hover { + border-color: var(--primary-border); + background: var(--primary-surface); +} + +.dayOutside { + color: var(--text-tertiary); +} + +.daySelected { + border-color: var(--primary); + background: var(--primary); + color: var(--active-contrast); + font-weight: 760; +} + +.daySelected:hover { + border-color: var(--primary-strong); + background: var(--primary-strong); + color: var(--active-contrast); +} + +.timeGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); +} + +.error { + color: var(--danger); + font-size: 12px; + line-height: 18px; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); +} + +@media (max-width: 760px) { + .trigger { + flex: 1 1 auto; + width: 100%; + max-width: none; + } +} diff --git a/src/shared/ui/TimeRangeFilter.test.jsx b/src/shared/ui/TimeRangeFilter.test.jsx new file mode 100644 index 0000000..9782dc1 --- /dev/null +++ b/src/shared/ui/TimeRangeFilter.test.jsx @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +import { datetimeTextToMs, normalizeRange, timeRangeLabel } from "@/shared/ui/TimeRangeFilter.jsx"; + +describe("TimeRangeFilter helpers", () => { + test("parses strict local date time text", () => { + const ms = datetimeTextToMs("2026-05-13 12:30"); + const date = new Date(ms); + + expect(date.getFullYear()).toBe(2026); + expect(date.getMonth()).toBe(4); + expect(date.getDate()).toBe(13); + expect(date.getHours()).toBe(12); + expect(date.getMinutes()).toBe(30); + expect(datetimeTextToMs("2026-13-13 12:30")).toBeNull(); + expect(datetimeTextToMs("")).toBe(""); + }); + + test("keeps range as a single display label", () => { + const startMs = datetimeTextToMs("2026-05-13 10:00"); + const endMs = datetimeTextToMs("2026-05-13 12:00"); + + expect(normalizeRange({ endMs, startMs })).toEqual({ endMs, startMs }); + expect(timeRangeLabel("时间", { endMs, startMs })).toContain(" - "); + expect(timeRangeLabel("时间", {})).toBe("时间"); + }); + +}); diff --git a/src/shared/ui/tableFilters.js b/src/shared/ui/tableFilters.js index 5154aae..6e5de79 100644 --- a/src/shared/ui/tableFilters.js +++ b/src/shared/ui/tableFilters.js @@ -1,18 +1,34 @@ -export function createTextColumnFilter({ loading = false, onChange, placeholder, value }) { +export function createTextColumnFilter({ activeLabel, activeValue, loading = false, onChange, onReset, placeholder, value }) { return { + activeLabel, + activeValue, loading, onChange, + onReset, placeholder, type: "text", value }; } -export function createOptionsColumnFilter({ emptyLabel, loading = false, onChange, options = [], placeholder, value }) { +export function createOptionsColumnFilter({ + activeLabel, + activeValue, + emptyLabel, + loading = false, + onChange, + onReset, + options = [], + placeholder, + value +}) { return { + activeLabel, + activeValue, emptyLabel, loading, onChange, + onReset, options, placeholder, value diff --git a/src/styles/app.css b/src/styles/app.css index 95619b1..1830843 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -3,7 +3,6 @@ @import "../features/dashboard/dashboard.css"; @import "../features/users/users.css"; @import "../features/menus/menus.css"; -@import "../features/notifications/notifications.css"; @import "./responsive.css"; #app, diff --git a/src/styles/shared-ui.css b/src/styles/shared-ui.css index dfa3bf1..38b69df 100644 --- a/src/styles/shared-ui.css +++ b/src/styles/shared-ui.css @@ -258,16 +258,16 @@ .MuiAutocomplete-root .MuiOutlinedInput-root.MuiAutocomplete-inputRoot:not(.MuiInputBase-multiline) { height: auto; min-height: var(--control-height); - align-items: flex-start; + align-items: center; flex-wrap: wrap; gap: var(--space-1); - padding: 5px 40px 5px var(--space-2); + padding: 4px 40px 4px var(--space-2); } .MuiAutocomplete-root .MuiOutlinedInput-root.MuiAutocomplete-inputRoot .MuiAutocomplete-input { - flex: 1 0 96px; + flex: 1 0 12px; width: auto !important; - min-width: 80px; + min-width: 12px; height: var(--admin-autocomplete-input-height); padding: 0 !important; line-height: var(--admin-autocomplete-input-height); @@ -280,26 +280,82 @@ } .MuiAutocomplete-root .MuiAutocomplete-tag { + --admin-multiselect-tag-bg: var(--admin-multiselect-tag-1); + display: inline-flex; + flex: 0 0 auto; max-width: calc(100% - var(--space-2)); height: var(--admin-tag-height); + align-items: center; margin: 0; - border: 1px solid var(--admin-tag-border); + border: 1px solid var(--admin-multiselect-tag-border); border-radius: var(--admin-tag-radius); - background: var(--admin-tag-bg); - color: var(--admin-tag-color); + background: var(--admin-multiselect-tag-bg); + box-shadow: var(--admin-multiselect-tag-shadow); + color: var(--admin-multiselect-tag-color); font-size: var(--admin-tag-font-size); font-weight: var(--admin-tag-font-weight); + line-height: 1; + white-space: nowrap; + animation: row-enter var(--motion-base) var(--ease-emphasized) both; + transition: + box-shadow var(--motion-fast) var(--ease-standard), + filter var(--motion-fast) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.MuiAutocomplete-root .MuiAutocomplete-tag:nth-of-type(6n + 2) { + --admin-multiselect-tag-bg: var(--admin-multiselect-tag-2); +} + +.MuiAutocomplete-root .MuiAutocomplete-tag:nth-of-type(6n + 3) { + --admin-multiselect-tag-bg: var(--admin-multiselect-tag-3); +} + +.MuiAutocomplete-root .MuiAutocomplete-tag:nth-of-type(6n + 4) { + --admin-multiselect-tag-bg: var(--admin-multiselect-tag-4); +} + +.MuiAutocomplete-root .MuiAutocomplete-tag:nth-of-type(6n + 5) { + --admin-multiselect-tag-bg: var(--admin-multiselect-tag-5); +} + +.MuiAutocomplete-root .MuiAutocomplete-tag:nth-of-type(6n) { + --admin-multiselect-tag-bg: var(--admin-multiselect-tag-6); +} + +.MuiAutocomplete-root .MuiAutocomplete-tag:hover { + box-shadow: var(--admin-multiselect-tag-shadow-hover); + filter: saturate(1.06) brightness(1.03); + transform: translateY(-1px); +} + +.MuiAutocomplete-root .MuiAutocomplete-tag:active { + transform: translateY(0) scale(0.98); } .MuiAutocomplete-root .MuiAutocomplete-tag .MuiChip-label { + overflow: hidden; padding: 0 var(--admin-tag-padding-x); + text-overflow: ellipsis; + white-space: nowrap; } .MuiAutocomplete-root .MuiAutocomplete-tag .MuiChip-deleteIcon { width: 18px; height: 18px; margin-right: var(--space-1); - color: var(--text-tertiary); + border-radius: var(--radius-pill); + color: rgba(255, 255, 255, 0.9); + transition: + background var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard), + transform var(--motion-fast) var(--ease-emphasized); +} + +.MuiAutocomplete-root .MuiAutocomplete-tag .MuiChip-deleteIcon:hover { + background: rgba(255, 255, 255, 0.16); + color: #ffffff; + transform: scale(1.08); } .admin-tag-list { @@ -312,9 +368,11 @@ .admin-tag { display: inline-flex; + flex: 0 0 auto; max-width: 100%; height: var(--admin-tag-height); align-items: center; + gap: var(--admin-tag-gap); overflow: hidden; border: 1px solid var(--admin-tag-border); border-radius: var(--admin-tag-radius); @@ -529,24 +587,52 @@ .status-badge { display: inline-flex; - height: var(--status-height); + box-sizing: border-box; + flex: 0 0 auto; + max-width: 100%; + height: var(--admin-tag-height); align-items: center; - gap: var(--space-2); - padding: 0 var(--space-3); - border: 1px solid var(--border) !important; - border-radius: var(--radius-pill) !important; + gap: var(--admin-tag-gap); + padding: 0 var(--admin-tag-padding-x); + border: 1px solid var(--admin-tag-border) !important; + border-radius: var(--admin-tag-radius) !important; background: var(--neutral-surface) !important; color: var(--text-secondary) !important; - font-size: var(--admin-font-size) !important; + font-size: var(--admin-tag-font-size) !important; + font-weight: var(--admin-tag-font-weight) !important; + line-height: 1; + white-space: nowrap; + word-break: keep-all; } .status-point { + display: block; + flex: 0 0 var(--space-2); width: var(--space-2); height: var(--space-2); border-radius: var(--radius-pill); background: var(--stopped); } +.status-badge.MuiChip-root { + gap: 0; + padding: 0; +} + +.status-badge.MuiChip-root .MuiChip-icon { + margin-left: var(--admin-tag-padding-x); + margin-right: 0; +} + +.status-badge.MuiChip-root .MuiChip-label { + overflow: hidden; + padding-left: var(--admin-tag-gap); + padding-right: var(--admin-tag-padding-x); + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; +} + .status-badge--running, .status-badge--succeeded { border-color: var(--success-border) !important; @@ -725,6 +811,42 @@ font-size: var(--admin-font-size); } +.form-drawer__section { + display: grid; + gap: var(--space-3); +} + +.form-drawer__section + .form-drawer__section { + padding-top: var(--space-4); + border-top: 1px solid var(--border-soft); +} + +.form-drawer__section-title { + color: var(--text-secondary); + font-size: 13px; + font-weight: 700; + line-height: 18px; +} + +.form-drawer__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-3); +} + +.form-drawer__grid > .MuiTextField-root, +.form-drawer__grid > .MuiFormControl-root, +.form-drawer__grid > .MuiAutocomplete-root, +.form-drawer__grid > .MuiFormControlLabel-root { + width: 100%; +} + +.form-drawer__section > .MuiFormControlLabel-root, +.form-drawer__grid > .MuiFormControlLabel-root { + min-height: var(--control-height); + margin: 0; +} + .form-drawer__actions { display: flex; justify-content: flex-end; @@ -878,6 +1000,17 @@ font-size: var(--admin-font-size); } +.pagination-bar__meta { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-3); +} + +.pagination-bar__page-size { + width: 96px; +} + .pagination-bar__actions { display: flex; align-items: center; @@ -926,6 +1059,7 @@ min-height: var(--table-row-height); padding: 0; border-bottom: 1px solid var(--row-border); + background: var(--bg-card); color: var(--text-secondary); column-gap: 0; font-size: var(--admin-font-size); @@ -971,7 +1105,16 @@ padding-right: calc(var(--admin-table-cell-x) + 12px); } +.admin-cell__head-content { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + gap: var(--space-2); +} + .admin-cell__head-label { + flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -985,6 +1128,7 @@ .admin-cell__head-trigger { display: inline-flex; + flex: 0 1 auto; max-width: 100%; min-width: 0; align-items: center; @@ -1013,6 +1157,12 @@ color: var(--primary); } +.admin-table-filter-reset-wrap { + display: inline-flex; + flex: 0 0 auto; + margin-left: auto; +} + .admin-cell--fixed-left, .admin-cell--fixed-right { position: sticky; @@ -1020,7 +1170,7 @@ display: flex; min-height: var(--table-row-height); align-items: center; - background: var(--bg-card); + background: inherit; } .admin-cell--fixed-left { @@ -1037,7 +1187,7 @@ .admin-row--head .admin-cell--fixed-right { z-index: 4; min-height: var(--table-head-height); - background: var(--bg-card-strong); + background: inherit; } .admin-column-resizer { diff --git a/src/styles/tokens.css b/src/styles/tokens.css index c1fcb4a..1a721c6 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -6,6 +6,7 @@ --admin-tag-height: var(--status-height); --admin-tag-radius: var(--radius-pill); --admin-tag-padding-x: var(--space-2); + --admin-tag-gap: var(--space-1); --admin-tag-font-size: var(--admin-font-size); --admin-tag-font-weight: 650; --table-head-height: 44px; @@ -85,6 +86,16 @@ --admin-tag-bg: var(--neutral-surface); --admin-tag-border: var(--neutral-border); --admin-tag-color: var(--text-secondary); + --admin-multiselect-tag-color: #ffffff; + --admin-multiselect-tag-border: rgba(255, 255, 255, 0.2); + --admin-multiselect-tag-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 6px 14px rgba(15, 23, 42, 0.14); + --admin-multiselect-tag-shadow-hover: inset 0 1px 0 rgba(255, 255, 255, 0.24), 0 8px 18px rgba(15, 23, 42, 0.18); + --admin-multiselect-tag-1: linear-gradient(135deg, #475569, #64748b); + --admin-multiselect-tag-2: linear-gradient(135deg, #52525b, #71717a); + --admin-multiselect-tag-3: linear-gradient(135deg, #57534e, #78716c); + --admin-multiselect-tag-4: linear-gradient(135deg, #4b5563, #6b7280); + --admin-multiselect-tag-5: linear-gradient(135deg, #435064, #667085); + --admin-multiselect-tag-6: linear-gradient(135deg, #5b5f6b, #7c8290); --chart-blue: #2563eb; --chart-green: #16a34a;