From dba61e808af9c54f13d93d26e73b251345ff3b8a Mon Sep 17 00:00:00 2001 From: ZuoZuo <68836346+Mrz-sakura@users.noreply.github.com> Date: Thu, 14 May 2026 00:30:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=90=8E=E5=8F=B0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD=E4=B8=8E=E8=8F=9C=E5=8D=95=E6=9D=83?= =?UTF-8?q?=E9=99=90=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 412 +++++++++++++ src/app/layout/Header.jsx | 16 + src/app/navigation/menu.js | 4 +- src/app/permissions.ts | 21 + src/app/providers.jsx | 5 +- src/app/router/DeferredPage.jsx | 7 +- src/app/timezone/TimeZoneProvider.jsx | 47 ++ .../dashboard/components/DashboardToolbar.jsx | 5 +- src/features/logs/components/LogTable.jsx | 12 +- .../menus/components/MenuPermissionDetail.jsx | 141 +++++ src/features/menus/components/MenuTable.jsx | 127 ---- .../menus/components/MenuTreePanel.jsx | 123 ++++ .../components/PermissionLibraryDrawer.jsx | 34 ++ src/features/menus/hooks/useMenusPage.js | 99 ++- src/features/menus/menus.css | 376 +++++++++++- .../menus/pages/MenuManagementPage.jsx | 55 +- .../components/NotificationList.jsx | 14 +- src/features/rooms/api.ts | 25 + src/features/rooms/hooks/useRoomPinsPage.js | 188 ++++++ src/features/rooms/pages/RoomPinsPage.jsx | 280 +++++++++ src/features/rooms/permissions.js | 3 + src/features/rooms/rooms.module.css | 7 + src/features/rooms/routes.js | 8 + src/features/rooms/schema.ts | 7 + .../users/components/UserProfileDrawer.jsx | 3 +- src/features/users/components/UserTable.jsx | 9 +- src/shared/api/generated/endpoints.ts | 200 ++++++ src/shared/api/generated/schema.d.ts | 572 ++++++++++++++++++ src/shared/api/types.ts | 38 ++ src/shared/ui/DataState.jsx | 5 +- src/shared/ui/DataTable.jsx | 5 +- src/shared/ui/DataTable.timezone.test.jsx | 48 ++ src/shared/ui/PageLoadBoundary.jsx | 80 +++ src/shared/ui/TimeText.jsx | 12 + src/shared/utils/time.js | 143 ++++- src/shared/utils/time.test.js | 21 + src/styles/layout.css | 27 +- src/styles/responsive.css | 4 + 38 files changed, 2979 insertions(+), 204 deletions(-) create mode 100644 src/app/timezone/TimeZoneProvider.jsx create mode 100644 src/features/menus/components/MenuPermissionDetail.jsx delete mode 100644 src/features/menus/components/MenuTable.jsx create mode 100644 src/features/menus/components/MenuTreePanel.jsx create mode 100644 src/features/menus/components/PermissionLibraryDrawer.jsx create mode 100644 src/features/rooms/hooks/useRoomPinsPage.js create mode 100644 src/features/rooms/pages/RoomPinsPage.jsx create mode 100644 src/shared/ui/DataTable.timezone.test.jsx create mode 100644 src/shared/ui/PageLoadBoundary.jsx create mode 100644 src/shared/ui/TimeText.jsx create mode 100644 src/shared/utils/time.test.js diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 5b0ff2b..2821d10 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,82 @@ ] } }, + "/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" + ] + } + }, + "/admin/rooms/pins": { + "get": { + "operationId": "listRoomPins", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "room-pin:view", + "x-permissions": [ + "room-pin:view" + ] + }, + "post": { + "operationId": "createRoomPin", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "room-pin:create", + "x-permissions": [ + "room-pin:create" + ] + } + }, + "/admin/rooms/pins/{pin_id}": { + "delete": { + "operationId": "cancelRoomPin", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "pin_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "room-pin:cancel", + "x-permissions": [ + "room-pin:cancel" + ] + } + }, "/app/users": { "get": { "operationId": "appListUsers", @@ -1471,6 +1869,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", diff --git a/src/app/layout/Header.jsx b/src/app/layout/Header.jsx index 195719f..5a1162f 100644 --- a/src/app/layout/Header.jsx +++ b/src/app/layout/Header.jsx @@ -16,6 +16,7 @@ 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 { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx"; import { changePassword } from "@/features/auth/api"; import { listNotifications } from "@/features/notifications/api"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; @@ -38,6 +39,7 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr const { can, logout, user } = useAuth(); const appScope = useAppScope(); const { showToast } = useToast(); + const { setTimeZone, timeZone, timeZoneOptions } = useTimeZone(); const ToggleIcon = isSidebarCollapsed ? MenuOpen : Menu; const userMenuOpen = Boolean(userMenuAnchor); const canViewNotifications = can(PERMISSIONS.notificationView); @@ -184,6 +186,20 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr {appScope.appCode} )} + setTimeZone(event.target.value)} + > + {timeZoneOptions.map((option) => ( + + {option.label} + + ))} + + ) : null} + +
+ {boundPermission?.name || menu.permissionCode || "未绑定"} + {menu.permissionCode || "-"} + {boundPermission ? kindLabel(boundPermission.kind) : "-"} +
+ + + {abilities.canLoadPermissions ? ( +
+
+
+

可绑定权限

+ {accessPermissions.length} 项 +
+ +
+
+ {accessPermissions.length ? ( + accessPermissions.map((permission) => { + const selected = permission.code === menu.permissionCode; + return ( + + ); + }) + ) : ( +
+
当前无数据
+
+ )} +
+
+ ) : null} + + ); +} + +function DetailField({ label, value }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/src/features/menus/components/MenuTable.jsx b/src/features/menus/components/MenuTable.jsx deleted file mode 100644 index fec349e..0000000 --- a/src/features/menus/components/MenuTable.jsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useState } from "react"; -import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; -import DragIndicatorOutlined from "@mui/icons-material/DragIndicatorOutlined"; -import EditOutlined from "@mui/icons-material/EditOutlined"; -import VisibilityOffOutlined from "@mui/icons-material/VisibilityOffOutlined"; -import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined"; -import { - AdminActionIconButton, - AdminRowActions, - adminListClasses -} from "@/shared/ui/AdminListLayout.jsx"; -import { DataTable } from "@/shared/ui/DataTable.jsx"; -import { createTextColumnFilter } from "@/shared/ui/tableFilters.js"; - -export function MenuTable({ abilities, flatMenus, onEdit, onQueryChange, onReorder, onRemove, onToggleVisible, query }) { - const [draggingMenu, setDraggingMenu] = useState(null); - const [dragOverId, setDragOverId] = useState(null); - const columns = [ - { - key: "drag", - header: "", - width: "44px", - render: () => abilities.canSortMenu ? : null - }, - { - key: "label", - label: "菜单", - width: "minmax(180px, 1.2fr)", - filter: createTextColumnFilter({ - placeholder: "搜索菜单、权限码", - value: query, - onChange: onQueryChange - }), - render: (menu) => {menu.label} - }, - { key: "path", label: "路由", width: "minmax(180px, 1.2fr)", render: (menu) => {menu.path || "-"} }, - { key: "permissionCode", label: "权限码", width: "minmax(180px, 1.2fr)", render: (menu) => menu.permissionCode || "-" }, - { key: "sort", label: "排序", width: "88px", render: (menu) => {menu.sort} }, - { key: "visible", label: "状态", width: "100px", render: (menu) => (menu.visible ? "显示" : "隐藏") }, - { - key: "actions", - label: "操作", - width: "minmax(150px, 0.8fr)", - render: (menu) => ( - - {abilities.canUpdateMenu ? ( - onEdit(menu)}> - - - ) : null} - {abilities.canVisibleMenu ? ( - onToggleVisible(menu)}> - {menu.visible ? : } - - ) : null} - {abilities.canDeleteMenu ? ( - onRemove(menu)}> - - - ) : null} - - ) - } - ]; - - return ( - getDragRowProps({ - canSort: abilities.canSortMenu, - dragOverId, - draggingMenu, - menu, - onDragEnd: () => { - setDraggingMenu(null); - setDragOverId(null); - }, - onDragOverRow: setDragOverId, - onDragStart: setDraggingMenu, - onReorder - })} - items={flatMenus} - minWidth="1120px" - rowKey={(menu) => menu.id || menu.code} - /> - ); -} - -function getDragRowProps({ canSort, dragOverId, draggingMenu, menu, onDragEnd, onDragOverRow, onDragStart, onReorder }) { - if (!canSort) { - return {}; - } - - const canDrop = draggingMenu && draggingMenu.id !== menu.id && (draggingMenu.parentId || null) === (menu.parentId || null); - const isDropTarget = canDrop && dragOverId === menu.id; - - return { - className: [adminListClasses.dragRow, isDropTarget ? adminListClasses.dragRowActive : ""].filter(Boolean).join(" "), - draggable: true, - onDragEnd, - onDragLeave: () => { - if (dragOverId === menu.id) { - onDragOverRow(null); - } - }, - onDragOver: (event) => { - if (canDrop) { - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - onDragOverRow(menu.id); - } - }, - onDragStart: (event) => { - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", String(menu.id)); - onDragStart(menu); - }, - onDrop: (event) => { - event.preventDefault(); - if (canDrop) { - onReorder(draggingMenu, menu); - } - onDragOverRow(null); - onDragEnd(); - } - }; -} diff --git a/src/features/menus/components/MenuTreePanel.jsx b/src/features/menus/components/MenuTreePanel.jsx new file mode 100644 index 0000000..dc1c073 --- /dev/null +++ b/src/features/menus/components/MenuTreePanel.jsx @@ -0,0 +1,123 @@ +import { useState } from "react"; +import DragIndicatorOutlined from "@mui/icons-material/DragIndicatorOutlined"; +import { SearchBox } from "@/shared/ui/SearchBox.jsx"; +import { adminListClasses } from "@/shared/ui/AdminListLayout.jsx"; + +export function MenuTreePanel({ + abilities, + menus, + onQueryChange, + onReorder, + onSelect, + query, + selectedMenuId, + total +}) { + const [draggingMenu, setDraggingMenu] = useState(null); + const [dragOverId, setDragOverId] = useState(null); + + return ( +
+
+
+

菜单树

+ 共 {total} 项 +
+ +
+
+ {menus.length ? ( + menus.map((menu) => { + const isSelected = menu.id === selectedMenuId; + const rowProps = getDragRowProps({ + canSort: abilities.canSortMenu, + dragOverId, + draggingMenu, + menu, + onDragEnd: () => { + setDraggingMenu(null); + setDragOverId(null); + }, + onDragOverRow: setDragOverId, + onDragStart: setDraggingMenu, + onReorder + }); + const { className = "", ...restRowProps } = rowProps; + + return ( + + ); + }) + ) : ( +
+
当前无数据
+
+ )} +
+
+ ); +} + +function getDragRowProps({ canSort, dragOverId, draggingMenu, menu, onDragEnd, onDragOverRow, onDragStart, onReorder }) { + if (!canSort) { + return {}; + } + + const canDrop = draggingMenu && draggingMenu.id !== menu.id && (draggingMenu.parentId || null) === (menu.parentId || null); + const isDropTarget = canDrop && dragOverId === menu.id; + + return { + className: [adminListClasses.dragRow, isDropTarget ? adminListClasses.dragRowActive : ""].filter(Boolean).join(" "), + draggable: true, + onDragEnd, + onDragLeave: () => { + if (dragOverId === menu.id) { + onDragOverRow(null); + } + }, + onDragOver: (event) => { + if (canDrop) { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + onDragOverRow(menu.id); + } + }, + onDragStart: (event) => { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", String(menu.id)); + onDragStart(menu); + }, + onDrop: (event) => { + event.preventDefault(); + if (canDrop) { + onReorder(draggingMenu, menu); + } + onDragOverRow(null); + onDragEnd(); + } + }; +} diff --git a/src/features/menus/components/PermissionLibraryDrawer.jsx b/src/features/menus/components/PermissionLibraryDrawer.jsx new file mode 100644 index 0000000..bf7af67 --- /dev/null +++ b/src/features/menus/components/PermissionLibraryDrawer.jsx @@ -0,0 +1,34 @@ +import { PermissionTable } from "@/features/menus/components/PermissionTable.jsx"; +import { SearchBox } from "@/shared/ui/SearchBox.jsx"; +import { SideDrawer } from "@/shared/ui/SideDrawer.jsx"; + +export function PermissionLibraryDrawer({ + abilities, + onClose, + onEdit, + onQueryChange, + onRemove, + open, + permissions, + query +}) { + return ( + + + + + ); +} diff --git a/src/features/menus/hooks/useMenusPage.js b/src/features/menus/hooks/useMenusPage.js index 628d1a6..8cc7b84 100644 --- a/src/features/menus/hooks/useMenusPage.js +++ b/src/features/menus/hooks/useMenusPage.js @@ -34,7 +34,11 @@ const emptyPermission = { code: "", description: "", kind: "button", name: "" }; const emptyData = { menus: [], permissions: [] }; export function useMenusPage() { - const [query, setQuery] = useState(""); + const [menuQuery, setMenuQuery] = useState(""); + const [accessPermissionQuery, setAccessPermissionQuery] = useState(""); + const [permissionLibraryQuery, setPermissionLibraryQuery] = useState(""); + const [permissionLibraryOpen, setPermissionLibraryOpen] = useState(false); + const [selectedMenuId, setSelectedMenuId] = useState(null); const [menuDrawerOpen, setMenuDrawerOpen] = useState(false); const [permissionDrawerOpen, setPermissionDrawerOpen] = useState(false); const [editingMenu, setEditingMenu] = useState(null); @@ -60,7 +64,7 @@ export function useMenusPage() { const flatMenus = useMemo(() => flattenMenus(data.menus || []), [data.menus]); const filteredFlatMenus = useMemo(() => { - const keyword = query.trim().toLowerCase(); + const keyword = menuQuery.trim().toLowerCase(); if (!keyword) { return flatMenus; } @@ -71,9 +75,9 @@ export function useMenusPage() { .includes(keyword), ); }); - }, [flatMenus, query]); + }, [flatMenus, menuQuery]); const filteredPermissions = useMemo(() => { - const keyword = query.trim().toLowerCase(); + const keyword = permissionLibraryQuery.trim().toLowerCase(); const items = data.permissions || []; if (!keyword) { return items; @@ -85,14 +89,47 @@ export function useMenusPage() { .includes(keyword), ); }); - }, [data.permissions, query]); + }, [data.permissions, permissionLibraryQuery]); + + const selectedMenu = useMemo(() => { + if (!flatMenus.length) { + return null; + } + const visibleSelection = filteredFlatMenus.find((menu) => menu.id === selectedMenuId); + const currentSelection = flatMenus.find((menu) => menu.id === selectedMenuId); + return visibleSelection || filteredFlatMenus[0] || currentSelection || flatMenus[0]; + }, [filteredFlatMenus, flatMenus, selectedMenuId]); + + const selectedPermission = useMemo(() => { + if (!selectedMenu?.permissionCode) { + return null; + } + return (data.permissions || []).find((permission) => permission.code === selectedMenu.permissionCode) || null; + }, [data.permissions, selectedMenu]); + + const accessPermissions = useMemo(() => { + const keyword = accessPermissionQuery.trim().toLowerCase(); + const items = (data.permissions || []).filter((permission) => permission.kind === "menu"); + if (!keyword) { + return items; + } + return items.filter((permission) => { + return [permission.name, permission.code, permission.description].some((value) => + String(value || "") + .toLowerCase() + .includes(keyword), + ); + }); + }, [accessPermissionQuery, data.permissions]); const changeQuery = (value) => { - setQuery(value); + setMenuQuery(value); }; const resetFilters = () => { - setQuery(""); + setMenuQuery(""); + setAccessPermissionQuery(""); + setPermissionLibraryQuery(""); }; const openCreateMenu = () => { @@ -111,7 +148,7 @@ export function useMenusPage() { path: menu.path || "", permissionCode: menu.permissionCode || "", sort: menu.sort || 0, - visible: Boolean(menu.visible), + visible: menu.visible !== false, }); setMenuDrawerOpen(true); }; @@ -217,6 +254,21 @@ export function useMenusPage() { } }; + const bindMenuPermission = async (menu, permissionCode) => { + if (!menu || !abilities.canUpdateMenu) { + return; + } + + try { + const payload = parseForm(menuFormSchema, menuToForm(menu, { permissionCode })); + await updateMenu(menu.id, payload); + showToast(permissionCode ? "访问权限已绑定" : "访问权限已清除", "success"); + reload(); + } catch (err) { + showToast(err.message || "保存访问权限失败", "error"); + } + }; + const reorderMenu = async (source, target) => { if (!source || !target || source.id === target.id || (source.parentId || null) !== (target.parentId || null)) { return; @@ -257,31 +309,46 @@ export function useMenusPage() { }; return { + accessPermissionQuery, + accessPermissions, abilities, + bindMenuPermission, changeQuery, + changeAccessPermissionQuery: setAccessPermissionQuery, + changeMenuQuery: setMenuQuery, + changePermissionLibraryQuery: setPermissionLibraryQuery, closeMenuDrawer: () => setMenuDrawerOpen(false), closePermissionDrawer: () => setPermissionDrawerOpen(false), + closePermissionLibrary: () => setPermissionLibraryOpen(false), editingMenu, editingPermission, error, flatMenus: filteredFlatMenus, + hasActiveFilters: Boolean(menuQuery || accessPermissionQuery || permissionLibraryQuery), loading, + menuQuery, menuDrawerOpen, menuForm, + menuTotal: flatMenus.length, menus: data.menus || [], openCreateMenu, openCreatePermission, openEditMenu, openEditPermission, + openPermissionLibrary: () => setPermissionLibraryOpen(true), + permissionLibraryOpen, + permissionLibraryQuery, permissionDrawerOpen, permissionForm, permissions: filteredPermissions, - query, reload, resetFilters, removeMenu, removePermission, reorderMenu, + selectMenu: (menu) => setSelectedMenuId(menu?.id || null), + selectedMenu, + selectedPermission, setMenuForm, setPermissionForm, submitMenu, @@ -294,3 +361,17 @@ export function useMenusPage() { function flattenMenus(menus, depth = 0) { return menus.flatMap((menu) => [{ ...menu, depth }, ...flattenMenus(menu.children || [], depth + 1)]); } + +function menuToForm(menu, overrides = {}) { + return { + code: menu.code, + icon: menu.icon || "menu", + label: menu.label, + parentId: menu.parentId ? String(menu.parentId) : "", + path: menu.path || "", + permissionCode: menu.permissionCode || "", + sort: menu.sort || 0, + visible: menu.visible !== false, + ...overrides, + }; +} diff --git a/src/features/menus/menus.css b/src/features/menus/menus.css index aa35c57..0b80d1c 100644 --- a/src/features/menus/menus.css +++ b/src/features/menus/menus.css @@ -1,13 +1,379 @@ -.permission-page-grid { +.menu-permission-layout { display: grid; flex: 1; - grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); - gap: 0; + grid-template-columns: 340px minmax(0, 1fr); min-width: 0; min-height: 0; overflow: hidden; } -.permission-page-grid .table-frame + .table-frame { - border-top: 1px solid var(--border); +.menu-tree-panel, +.menu-detail-panel { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--bg-card); +} + +.menu-tree-panel { + border-right: 1px solid var(--border); +} + +.menu-tree-panel__header, +.menu-detail-header, +.menu-access-card, +.menu-permission-picker__header { + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--border); +} + +.menu-tree-panel__header, +.menu-permission-picker__header { + display: grid; + gap: var(--space-3); +} + +.menu-panel-title { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.menu-panel-title h2, +.menu-panel-title h3, +.menu-detail-title h2 { + margin: 0; + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 760; +} + +.menu-panel-title span, +.menu-detail-title span, +.menu-detail-field span { + color: var(--text-tertiary); + font-size: var(--admin-font-size); +} + +.menu-tree-list, +.menu-permission-list { + flex: 1; + min-height: 0; + overflow: auto; +} + +.menu-tree-item { + display: grid; + width: 100%; + min-height: 58px; + align-items: center; + grid-template-columns: 22px minmax(0, 1fr) auto; + gap: var(--space-3); + padding-top: var(--space-2); + padding-right: var(--space-4); + padding-bottom: var(--space-2); + border: 0; + border-bottom: 1px solid var(--row-border); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font: inherit; + text-align: left; + transition: + background var(--motion-fast) var(--ease-standard), + box-shadow var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard); +} + +.menu-tree-item:hover { + background: var(--bg-card-strong); +} + +.menu-tree-item--active, +.menu-tree-item--active:hover { + background: var(--primary-surface); + box-shadow: inset 3px 0 0 var(--primary); + color: var(--text-primary); +} + +.menu-tree-item__drag { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); +} + +.menu-tree-item__content { + display: grid; + min-width: 0; + gap: var(--space-1); +} + +.menu-tree-item__name, +.menu-tree-item__meta { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.menu-tree-item__name { + color: inherit; + font-weight: 700; +} + +.menu-tree-item__meta { + color: var(--text-tertiary); + font-size: var(--admin-font-size); +} + +.menu-tree-status { + display: inline-flex; + height: 24px; + align-items: center; + justify-content: center; + padding: 0 var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + background: var(--neutral-surface); + color: var(--stopped); + font-size: var(--admin-font-size); + font-weight: 650; + white-space: nowrap; +} + +.menu-tree-status.is-visible { + border-color: var(--success-border); + background: var(--success-surface); + color: var(--success); +} + +.menu-tree-status.is-hidden { + color: var(--stopped); +} + +.menu-detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); +} + +.menu-detail-title { + display: grid; + min-width: 0; + gap: var(--space-2); +} + +.menu-detail-title h2 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.menu-detail-fields { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-bottom: 1px solid var(--border); +} + +.menu-detail-field { + display: grid; + min-width: 0; + gap: var(--space-2); + padding: var(--space-4) var(--space-5); + border-right: 1px solid var(--row-border); +} + +.menu-detail-field:last-child { + border-right: 0; +} + +.menu-detail-field strong { + min-width: 0; + overflow-wrap: anywhere; + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 700; +} + +.menu-access-card { + display: grid; + gap: var(--space-3); +} + +.menu-access-card__header { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.menu-bound-permission { + display: grid; + align-items: center; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.2fr) auto; + gap: var(--space-3); + min-height: var(--control-height); + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong); + color: var(--text-secondary); + font-size: var(--admin-font-size); +} + +.menu-bound-permission span, +.menu-bound-permission code { + min-width: 0; + overflow-wrap: anywhere; +} + +.menu-bound-permission span:first-child { + color: var(--text-primary); + font-weight: 700; +} + +.menu-bound-permission code, +.menu-permission-option code { + color: var(--text-tertiary); + font-family: inherit; + font-size: var(--admin-font-size); +} + +.menu-permission-picker { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; +} + +.menu-permission-option { + display: grid; + width: 100%; + min-height: 56px; + align-items: center; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-3); + padding: var(--space-3) var(--space-5); + border: 0; + border-bottom: 1px solid var(--row-border); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font: inherit; + text-align: left; + transition: + background var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard); +} + +.menu-permission-option:hover { + background: var(--bg-card-strong); +} + +.menu-permission-option:disabled { + cursor: default; +} + +.menu-permission-option--active, +.menu-permission-option--active:hover { + background: var(--primary-surface); + color: var(--text-primary); +} + +.menu-permission-option > span { + display: grid; + min-width: 0; + gap: var(--space-1); +} + +.menu-permission-option strong { + min-width: 0; + overflow: hidden; + color: inherit; + font-size: var(--admin-font-size); + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.menu-permission-option em { + display: inline-flex; + height: 24px; + align-items: center; + padding: 0 var(--space-2); + border-radius: var(--radius-pill); + background: var(--neutral-surface); + color: var(--text-tertiary); + font-style: normal; + font-size: var(--admin-font-size); + font-weight: 650; + white-space: nowrap; +} + +.menu-detail-empty, +.empty-state--compact { + display: grid; + flex: 1; + min-height: 180px; + place-items: center; +} + +.permission-library-drawer { + width: min(920px, 100vw); +} + +.permission-library-drawer__body { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; +} + +.permission-library-drawer__body .search-box { + flex: 0 0 auto; +} + +.permission-library-drawer__body .table-frame { + flex: 1; + min-height: 0; +} + +@media (max-width: 980px) { + .menu-permission-layout { + grid-template-columns: 1fr; + } + + .menu-tree-panel { + min-height: 320px; + border-right: 0; + border-bottom: 1px solid var(--border); + } +} + +@media (max-width: 720px) { + .menu-detail-header, + .menu-access-card__header { + align-items: stretch; + flex-direction: column; + } + + .menu-detail-fields, + .menu-bound-permission { + grid-template-columns: 1fr; + } + + .menu-detail-field { + border-right: 0; + border-bottom: 1px solid var(--row-border); + } + + .menu-detail-field:last-child { + border-bottom: 0; + } } diff --git a/src/features/menus/pages/MenuManagementPage.jsx b/src/features/menus/pages/MenuManagementPage.jsx index 8b5898e..15438d7 100644 --- a/src/features/menus/pages/MenuManagementPage.jsx +++ b/src/features/menus/pages/MenuManagementPage.jsx @@ -1,4 +1,5 @@ import Add from "@mui/icons-material/Add"; +import ShieldOutlined from "@mui/icons-material/ShieldOutlined"; import SyncOutlined from "@mui/icons-material/SyncOutlined"; import { DataState } from "@/shared/ui/DataState.jsx"; import { @@ -9,9 +10,10 @@ import { AdminListToolbar, } from "@/shared/ui/AdminListLayout.jsx"; import { MenuFormDrawer } from "@/features/menus/components/MenuFormDrawer.jsx"; -import { MenuTable } from "@/features/menus/components/MenuTable.jsx"; +import { MenuPermissionDetail } from "@/features/menus/components/MenuPermissionDetail.jsx"; +import { MenuTreePanel } from "@/features/menus/components/MenuTreePanel.jsx"; +import { PermissionLibraryDrawer } from "@/features/menus/components/PermissionLibraryDrawer.jsx"; import { PermissionFormDrawer } from "@/features/menus/components/PermissionFormDrawer.jsx"; -import { PermissionTable } from "@/features/menus/components/PermissionTable.jsx"; import { useMenusPage } from "@/features/menus/hooks/useMenusPage.js"; export function MenuManagementPage() { @@ -22,7 +24,7 @@ export function MenuManagementPage() { + } actions={ <> @@ -31,6 +33,11 @@ export function MenuManagementPage() { ) : null} + {page.abilities.canLoadPermissions ? ( + + + + ) : null} {page.abilities.canCreatePermission ? ( @@ -47,25 +54,29 @@ export function MenuManagementPage() { -
- + + -
@@ -79,6 +90,16 @@ export function MenuManagementPage() { open={page.menuDrawerOpen} setForm={page.setMenuForm} /> +
{item.title}
{item.content}
-
{formatDate(item.createdAt)}
+
- {!item.readAt && abilities.canRead ? ( + {!item.readAtMs && !item.readAt && abilities.canRead ? ( ) : ( - {item.readAt ? "已读" : "未读"} + {item.readAtMs || item.readAt ? "已读" : "未读"} )} {abilities.canDelete ? ( +
+ ) : null} + + + +
+ pin.id} + /> + {total > 0 ? ( + + ) : null} +
+
+ + + + options} + getOptionLabel={(option) => roomOptionLabel(option)} + isOptionEqualToValue={(option, value) => option.roomId === value.roomId} + loading={page.roomOptionsLoading} + noOptionsText="当前无数据" + options={page.roomOptions} + renderInput={(params) => ( + + )} + renderOption={(props, option) => ( +
  • + +
  • + )} + size="small" + value={page.selectedRoom} + onChange={(_, option) => page.setForm({ ...page.form, roomId: option?.roomId || "" })} + onInputChange={(_, value) => page.setRoomSearch(value)} + /> + page.setForm({ ...page.form, weight: event.target.value })} + /> + page.setForm({ ...page.form, durationDays: event.target.value })} + /> +
    + + ); +} + +function PinRoomIdentity({ room = {} }) { + return ( +
    + + {room.coverUrl ? : } + +
    +
    {room.title || "-"}
    +
    长ID {room.roomId || "-"}
    +
    短ID {room.roomShortId || "-"}
    +
    +
    + ); +} + +function PinUserIdentity({ user = {} }) { + return ( +
    + + {user.avatar ? : } + +
    +
    {user.username || "-"}
    +
    长ID {user.userId || "-"}
    +
    短ID {user.displayUserId || "-"}
    +
    +
    + ); +} + +function RoomPinActions({ page, pin }) { + const canCancel = page.abilities.canPinCancel && pin.status === "active" && Number(pin.remainingMs || 0) > 0; + if (!canCancel) { + return -; + } + return ( +
    + + + page.cancelPin(pin)} + > + + + + +
    + ); +} + +function roomOptionLabel(room) { + if (!room) { + return ""; + } + return [room.title, room.roomShortId, room.roomId].filter(Boolean).join(" / "); +} + +function formatRemaining(pin) { + if (pin.status === "cancelled") { + return "-"; + } + const ms = Number(pin.remainingMs || 0); + if (ms <= 0) { + return "0天"; + } + const dayMs = 24 * 60 * 60 * 1000; + const days = Math.floor(ms / dayMs); + const hours = Math.floor((ms % dayMs) / (60 * 60 * 1000)); + if (days > 0) { + return `${days}天${hours > 0 ? `${hours}小时` : ""}`; + } + return `${Math.max(hours, 1)}小时`; +} diff --git a/src/features/rooms/permissions.js b/src/features/rooms/permissions.js index f0da236..11f1835 100644 --- a/src/features/rooms/permissions.js +++ b/src/features/rooms/permissions.js @@ -6,6 +6,9 @@ export function useRoomAbilities() { return { canDelete: can(PERMISSIONS.roomDelete), + canPinCancel: can(PERMISSIONS.roomPinCancel), + canPinCreate: can(PERMISSIONS.roomPinCreate), + canPinView: can(PERMISSIONS.roomPinView), 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..d9a126d 100644 --- a/src/features/rooms/rooms.module.css +++ b/src/features/rooms/rooms.module.css @@ -47,6 +47,13 @@ gap: var(--space-3); } +.toolbarActions { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: var(--space-2); +} + .search { flex: 0 1 320px; } diff --git a/src/features/rooms/routes.js b/src/features/rooms/routes.js index 64cd24f..a265df2 100644 --- a/src/features/rooms/routes.js +++ b/src/features/rooms/routes.js @@ -8,5 +8,13 @@ export const roomRoutes = [ pageKey: "room-list", path: "/rooms", permission: PERMISSIONS.roomView + }, + { + label: "房间置顶", + loader: () => import("./pages/RoomPinsPage.jsx").then((module) => module.RoomPinsPage), + menuCode: MENU_CODES.roomPins, + pageKey: "room-pins", + path: "/rooms/pins", + permission: PERMISSIONS.roomPinView } ]; diff --git a/src/features/rooms/schema.ts b/src/features/rooms/schema.ts index 7da664a..5dcf17a 100644 --- a/src/features/rooms/schema.ts +++ b/src/features/rooms/schema.ts @@ -13,5 +13,12 @@ export const roomStatusSchema = z.object({ status: z.string().trim().refine((value) => value === "active" || value === "closed", "房间状态不正确") }); +export const roomPinCreateSchema = z.object({ + durationDays: z.coerce.number().int().min(1, "请输入置顶天数").max(3650, "置顶时间不能超过 3650 天").default(30), + roomId: z.string().trim().min(1, "请选择房间").max(64, "房间 ID 不正确"), + weight: z.coerce.number().int().min(0, "权重不能小于 0").default(0) +}); + export type RoomUpdateForm = z.infer; export type RoomStatusForm = z.infer; +export type RoomPinCreateForm = z.infer; diff --git a/src/features/users/components/UserProfileDrawer.jsx b/src/features/users/components/UserProfileDrawer.jsx index ff97fd9..72b5e3c 100644 --- a/src/features/users/components/UserProfileDrawer.jsx +++ b/src/features/users/components/UserProfileDrawer.jsx @@ -1,5 +1,6 @@ import Person from "@mui/icons-material/Person"; import { SideDrawer } from "@/shared/ui/SideDrawer.jsx"; +import { TimeText } from "@/shared/ui/TimeText.jsx"; import { UserStatusBadge } from "./UserStatusBadge.jsx"; export function UserProfileDrawer({ onClose, open, user }) { @@ -26,7 +27,7 @@ export function UserProfileDrawer({ onClose, open, user }) { } /> - + } /> diff --git a/src/features/users/components/UserTable.jsx b/src/features/users/components/UserTable.jsx index 704bd05..050e500 100644 --- a/src/features/users/components/UserTable.jsx +++ b/src/features/users/components/UserTable.jsx @@ -9,6 +9,7 @@ import Checkbox from "@mui/material/Checkbox"; import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; +import { formatMillis } from "@/shared/utils/time.js"; import { UserStatusBadge } from "./UserStatusBadge.jsx"; export function UserTable({ @@ -110,7 +111,13 @@ export function UserTable({ render: (user) => }, { key: "mfa", label: "MFA", width: "minmax(80px, 0.72fr)" }, - { key: "lastLogin", label: "最后登录", width: "minmax(120px, 0.9fr)", className: "muted" }, + { + key: "lastLogin", + label: "最后登录", + width: "minmax(170px, 0.9fr)", + className: "muted", + render: (user) => formatMillis(user.lastLoginMs ?? user.lastLoginAtMs ?? user.lastLogin) + }, { key: "actions", label: "操作", diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 8b7b0b8..ece5292 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", @@ -23,32 +24,40 @@ export const API_OPERATIONS = { appUpdateUser: "appUpdateUser", batchUpdateUserStatus: "batchUpdateUserStatus", cancelJob: "cancelJob", + cancelRoomPin: "cancelRoomPin", 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", createRole: "createRole", + createRoomPin: "createRoomPin", createTaskDefinition: "createTaskDefinition", createUser: "createUser", 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 +76,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", @@ -88,12 +102,16 @@ export const API_OPERATIONS = { listNotifications: "listNotifications", listOperationLogs: "listOperationLogs", listPermissions: "listPermissions", + listPlatforms: "listPlatforms", listRechargeBills: "listRechargeBills", + listRechargeProducts: "listRechargeProducts", listRegions: "listRegions", + listRegistrationRewardClaims: "listRegistrationRewardClaims", listResourceGrants: "listResourceGrants", listResourceGroups: "listResourceGroups", listResources: "listResources", listRoles: "listRoles", + listRoomPins: "listRoomPins", listRooms: "listRooms", listSystemMenus: "listSystemMenus", listTaskDefinitions: "listTaskDefinitions", @@ -114,22 +132,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 +179,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, @@ -210,6 +242,13 @@ export const API_ENDPOINTS: Record = { permission: "job:cancel", permissions: ["job:cancel"] }, + cancelRoomPin: { + method: "DELETE", + operationId: API_OPERATIONS.cancelRoomPin, + path: "/v1/admin/rooms/pins/{pin_id}", + permission: "room-pin:cancel", + permissions: ["room-pin:cancel"] + }, changePassword: { method: "POST", operationId: API_OPERATIONS.changePassword, @@ -229,6 +268,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 +296,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 +338,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, @@ -313,6 +380,13 @@ export const API_ENDPOINTS: Record = { permission: "role:create", permissions: ["role:create","role:manage"] }, + createRoomPin: { + method: "POST", + operationId: API_OPERATIONS.createRoomPin, + path: "/v1/admin/rooms/pins", + permission: "room-pin:create", + permissions: ["room-pin:create"] + }, createTaskDefinition: { method: "POST", operationId: API_OPERATIONS.createTaskDefinition, @@ -348,6 +422,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, @@ -390,6 +471,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 +604,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 +632,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 +672,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 +700,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, @@ -661,6 +784,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 +798,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 +812,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, @@ -703,6 +847,13 @@ export const API_ENDPOINTS: Record = { permission: "role:view", permissions: ["role:view"] }, + listRoomPins: { + method: "GET", + operationId: API_OPERATIONS.listRoomPins, + path: "/v1/admin/rooms/pins", + permission: "room-pin:view", + permissions: ["room-pin:view"] + }, listRooms: { method: "GET", operationId: API_OPERATIONS.listRooms, @@ -831,6 +982,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 +1010,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 +1024,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 +1073,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 +1094,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 +1136,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..4d44bb0 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,54 @@ 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; + }; + "/admin/rooms/pins": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listRoomPins"]; + put?: never; + post: operations["createRoomPin"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/rooms/pins/{pin_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["cancelRoomPin"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/app/users": { parameters: { query?: never; @@ -868,6 +1108,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; @@ -1993,6 +2249,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 +2420,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 +2757,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 +2932,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 +2956,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 +3352,68 @@ 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"]; + }; + }; + listRoomPins: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + createRoomPin: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + cancelRoomPin: { + parameters: { + query?: never; + header?: never; + path: { + pin_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; appListUsers: { parameters: { query?: never; @@ -2950,6 +3510,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; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index dcc53f4..db0dce2 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -43,6 +43,7 @@ export interface AdminUserDto { account: string; id: EntityId; lastLogin?: string; + lastLoginMs?: number | null; mfa?: string; mfaEnabled?: boolean; name: string; @@ -146,12 +147,14 @@ export interface DashboardOverviewDto { }; unreadNotifications: number; updatedAt?: string; + updatedAtMs?: number; usersTotal: number; } export interface LogDto { action?: string; createdAt?: string; + createdAtMs?: number; detail?: string; id: number; ip?: string; @@ -164,9 +167,11 @@ export interface LogDto { export interface NotificationDto { content: string; createdAt?: string; + createdAtMs?: number; id: number; level: string; readAt?: string | null; + readAtMs?: number | null; title: string; } @@ -378,6 +383,7 @@ export interface RoomDto { ownerUserId?: string; regionName?: string; roomId: string; + roomShortId?: string; seatCount?: number; sortScore?: number; status?: string; @@ -386,6 +392,38 @@ export interface RoomDto { visibleRegionId?: number; } +export interface RoomPinRoomDto { + coverUrl?: string; + regionName?: string; + roomId: string; + roomShortId?: string; + status?: string; + title?: string; + visibleRegionId?: number; +} + +export interface RoomPinDto { + cancelledAtMs?: number; + cancelledByAdminId?: number; + createdAtMs?: number; + createdByAdminId?: number; + expiresAtMs?: number; + id: EntityId; + pinnedAtMs?: number; + remainingMs?: number; + room: RoomPinRoomDto; + status?: string; + updatedAtMs?: number; + user?: RoomOwnerDto; + weight?: number; +} + +export interface RoomPinPayload { + durationDays?: number; + roomId: string; + weight: number; +} + export interface RoomOwnerDto { avatar?: string; displayUserId?: string; diff --git a/src/shared/ui/DataState.jsx b/src/shared/ui/DataState.jsx index bfe2010..cce93cd 100644 --- a/src/shared/ui/DataState.jsx +++ b/src/shared/ui/DataState.jsx @@ -1,7 +1,10 @@ import Refresh from "@mui/icons-material/Refresh"; import { Button } from "./Button.jsx"; +import { usePageLoadingStatus } from "./PageLoadBoundary.jsx"; + +export function DataState({ error, loading, children, onRetry, reportPageLoading = true }) { + usePageLoadingStatus(loading, reportPageLoading); -export function DataState({ error, loading, children, onRetry }) { if (loading) { return (
    diff --git a/src/shared/ui/DataTable.jsx b/src/shared/ui/DataTable.jsx index c5ed263..6791145 100644 --- a/src/shared/ui/DataTable.jsx +++ b/src/shared/ui/DataTable.jsx @@ -4,6 +4,7 @@ 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 { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx"; import { Button } from "@/shared/ui/Button.jsx"; const DEFAULT_COLUMN_WIDTH = "minmax(120px, 1fr)"; @@ -26,11 +27,13 @@ export function DataTable({ title, total }) { + const { timeZone } = useTimeZone(); const [columnWidths, setColumnWidths] = useState({}); const [filterMenu, setFilterMenu] = useState({ anchorEl: null, columnKey: "" }); const [filterQuery, setFilterQuery] = useState(""); const [resizeSession, setResizeSession] = useState(null); const safeItems = Array.isArray(items) ? items : []; + const renderContext = useMemo(() => (context ? { ...context, timeZone } : { timeZone }), [context, timeZone]); const preparedColumns = useMemo( () => @@ -181,7 +184,7 @@ export function DataTable({
    {preparedColumns.map((column) => (
    - {column.render ? column.render(item, index, context) : displayValue(item[column.key])} + {column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
    ))}
    diff --git a/src/shared/ui/DataTable.timezone.test.jsx b/src/shared/ui/DataTable.timezone.test.jsx new file mode 100644 index 0000000..8af4fe2 --- /dev/null +++ b/src/shared/ui/DataTable.timezone.test.jsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, expect, test } from "vitest"; +import { TimeZoneProvider, useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; +import { DataTable } from "./DataTable.jsx"; + +const epochMillis = Date.UTC(2026, 4, 9, 19, 21, 0); + +beforeEach(() => { + window.localStorage.clear(); +}); + +test("rerenders table timestamp cells when display timezone changes", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + expect(screen.getByText("2026-05-10 03:21:00")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "UTC" })); + + expect(screen.getByText("2026-05-09 19:21:00")).toBeInTheDocument(); +}); + +function TimezoneTableFixture() { + const { setTimeZone } = useTimeZone(); + const columns = [ + { + key: "createdAtMs", + label: "创建时间", + render: (item) => formatMillis(item.createdAtMs) + } + ]; + + return ( + <> + + item.id} /> + + ); +} diff --git a/src/shared/ui/PageLoadBoundary.jsx b/src/shared/ui/PageLoadBoundary.jsx new file mode 100644 index 0000000..32f521b --- /dev/null +++ b/src/shared/ui/PageLoadBoundary.jsx @@ -0,0 +1,80 @@ +import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useState } from "react"; +import { PageSkeleton } from "./PageSkeleton.jsx"; + +const PageLoadContext = createContext(null); +const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + +export function PageLoadBoundary({ children, pageKey }) { + const [loaders, setLoaders] = useState(() => new Map()); + const [released, setReleased] = useState(false); + + useEffect(() => { + setLoaders(new Map()); + setReleased(false); + }, [pageKey]); + + const reportLoading = useCallback((id, loading) => { + setLoaders((current) => { + if (loading === null) { + if (!current.has(id)) { + return current; + } + const next = new Map(current); + next.delete(id); + return next; + } + + if (current.get(id) === loading) { + return current; + } + + const next = new Map(current); + next.set(id, loading); + return next; + }); + }, []); + + const hasPendingLoader = useMemo(() => Array.from(loaders.values()).some(Boolean), [loaders]); + const showSkeleton = !released && hasPendingLoader; + + useIsomorphicLayoutEffect(() => { + if (!released && loaders.size > 0 && !hasPendingLoader) { + setReleased(true); + } + }, [hasPendingLoader, loaders.size, released]); + + const contextValue = useMemo(() => ({ reportLoading }), [reportLoading]); + + return ( + +
    + {showSkeleton ? : null} +
    + {children} +
    +
    +
    + ); +} + +export function usePageLoadingStatus(loading, enabled = true) { + const context = useContext(PageLoadContext); + const id = useId(); + + useIsomorphicLayoutEffect(() => { + if (!context || !enabled) { + return undefined; + } + + context.reportLoading(id, Boolean(loading)); + + return () => { + context.reportLoading(id, null); + }; + }, [context, enabled, id, loading]); +} diff --git a/src/shared/ui/TimeText.jsx b/src/shared/ui/TimeText.jsx new file mode 100644 index 0000000..5379df8 --- /dev/null +++ b/src/shared/ui/TimeText.jsx @@ -0,0 +1,12 @@ +import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx"; + +export function TimeText({ className, component: Component = "span", value }) { + const { formatMillis, timeZoneLabel } = useTimeZone(); + const text = formatMillis(value); + + return ( + + {text} + + ); +} diff --git a/src/shared/utils/time.js b/src/shared/utils/time.js index 3ea9cd3..94dc7d4 100644 --- a/src/shared/utils/time.js +++ b/src/shared/utils/time.js @@ -1,22 +1,131 @@ -export function formatTime(date = new Date()) { - return date.toLocaleTimeString("zh-CN", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit" - }); +export const DEFAULT_TIME_ZONE = "Asia/Shanghai"; +export const TIME_ZONE_STORAGE_KEY = "hyapp-admin.display-time-zone"; +export const TIME_ZONE_OPTIONS = [ + { label: "中国时间", value: "Asia/Shanghai" }, + { label: "UTC", value: "UTC" } +]; + +let activeTimeZone = DEFAULT_TIME_ZONE; + +const formatterCache = new Map(); + +export function normalizeTimeZone(value) { + return TIME_ZONE_OPTIONS.some((option) => option.value === value) ? value : DEFAULT_TIME_ZONE; } -export function formatDateTime(value) { - if (!value) { - return "-"; - } - return new Date(value).toLocaleString("zh-CN", { hour12: false }); +export function getActiveTimeZone() { + return activeTimeZone; } -export function formatMillis(value) { - if (!value) { - return "-"; - } - return formatDateTime(Number(value)); +export function setActiveTimeZone(value) { + activeTimeZone = normalizeTimeZone(value); + return activeTimeZone; +} + +export function getTimeZoneLabel(value) { + return TIME_ZONE_OPTIONS.find((option) => option.value === value)?.label || value || "-"; +} + +export function readStoredTimeZone(storage = globalThis.localStorage) { + try { + return normalizeTimeZone(storage?.getItem(TIME_ZONE_STORAGE_KEY)); + } catch { + return DEFAULT_TIME_ZONE; + } +} + +export function writeStoredTimeZone(value, storage = globalThis.localStorage) { + const normalized = normalizeTimeZone(value); + try { + storage?.setItem(TIME_ZONE_STORAGE_KEY, normalized); + } catch { + // 仅影响偏好持久化,不能阻断本次页面内切换。 + } + return normalized; +} + +export function toEpochMillis(value) { + if (value === null || value === undefined || value === "") { + return null; + } + if (value instanceof Date) { + return finitePositiveMillis(value.getTime()); + } + if (typeof value === "number") { + return finitePositiveMillis(value); + } + if (typeof value !== "string") { + return null; + } + + const text = value.trim(); + if (!text) { + return null; + } + if (/^\d+$/.test(text)) { + return finitePositiveMillis(Number(text)); + } + if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(text)) { + return null; + } + return finitePositiveMillis(Date.parse(text)); +} + +export function formatTime(value = Date.now(), timeZone = activeTimeZone) { + const parts = dateTimeParts(value, timeZone); + return parts ? `${parts.hour}:${parts.minute}:${parts.second}` : "-"; +} + +export function formatDateTime(value, timeZone = activeTimeZone) { + const parts = dateTimeParts(value, timeZone); + return parts ? `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}` : "-"; +} + +export function formatMillis(value, timeZone = activeTimeZone) { + return formatDateTime(value, timeZone); +} + +function finitePositiveMillis(value) { + return Number.isFinite(value) && value > 0 ? value : null; +} + +function dateTimeParts(value, timeZone) { + const epochMillis = toEpochMillis(value); + if (!epochMillis) { + return null; + } + + const partMap = Object.fromEntries( + getFormatter(normalizeTimeZone(timeZone)) + .formatToParts(epochMillis) + .filter((part) => part.type !== "literal") + .map((part) => [part.type, part.value]) + ); + return { + day: partMap.day, + hour: partMap.hour, + minute: partMap.minute, + month: partMap.month, + second: partMap.second, + year: partMap.year + }; +} + +function getFormatter(timeZone) { + if (!formatterCache.has(timeZone)) { + formatterCache.set( + timeZone, + new Intl.DateTimeFormat("en-CA", { + day: "2-digit", + hour: "2-digit", + hourCycle: "h23", + minute: "2-digit", + month: "2-digit", + second: "2-digit", + timeZone, + year: "numeric" + }) + ); + } + return formatterCache.get(timeZone); } diff --git a/src/shared/utils/time.test.js b/src/shared/utils/time.test.js new file mode 100644 index 0000000..b227df2 --- /dev/null +++ b/src/shared/utils/time.test.js @@ -0,0 +1,21 @@ +import { describe, expect, test } from "vitest"; +import { formatMillis, normalizeTimeZone, toEpochMillis } from "./time.js"; + +describe("time formatting", () => { + test("formats the same epoch milliseconds in UTC and China time", () => { + const epochMillis = Date.UTC(2026, 4, 9, 19, 21, 0); + + expect(formatMillis(epochMillis, "UTC")).toBe("2026-05-09 19:21:00"); + expect(formatMillis(epochMillis, "Asia/Shanghai")).toBe("2026-05-10 03:21:00"); + }); + + test("keeps ambiguous local date strings out of table rendering", () => { + expect(toEpochMillis("2026-05-09 19:21:00")).toBeNull(); + expect(formatMillis("2026-05-09T19:21:00Z", "Asia/Shanghai")).toBe("2026-05-10 03:21:00"); + }); + + test("normalizes unsupported zones to the product default", () => { + expect(normalizeTimeZone("UTC")).toBe("UTC"); + expect(normalizeTimeZone("America/Los_Angeles")).toBe("Asia/Shanghai"); + }); +}); diff --git a/src/styles/layout.css b/src/styles/layout.css index e9fa9b6..7c83f89 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -168,11 +168,17 @@ flex: 0 0 156px; } -.app-switch .MuiOutlinedInput-root { +.timezone-switch { + flex: 0 0 124px; +} + +.app-switch .MuiOutlinedInput-root, +.timezone-switch .MuiOutlinedInput-root { background: var(--bg-input); } -.app-switch .MuiSelect-select { +.app-switch .MuiSelect-select, +.timezone-switch .MuiSelect-select { font-weight: 650; } @@ -734,6 +740,23 @@ animation: page-enter var(--motion-slow) var(--ease-emphasized) both; } +.page-load-boundary { + position: relative; + min-height: 0; +} + +.page-load-boundary__content { + min-height: 0; +} + +.page-load-boundary__content--hidden { + position: absolute; + inset: 0; + width: 100%; + visibility: hidden; + pointer-events: none; +} + .page-skeleton { display: grid; height: calc(100vh - var(--header-height) - var(--space-8) - var(--space-4)); diff --git a/src/styles/responsive.css b/src/styles/responsive.css index 6816a0f..63be812 100644 --- a/src/styles/responsive.css +++ b/src/styles/responsive.css @@ -69,6 +69,10 @@ display: none; } + .timezone-switch { + flex-basis: 112px; + } + .workspace { padding: 16px; }