更新后台管理功能与菜单权限布局
This commit is contained in:
parent
dd8cd5d952
commit
dba61e808a
@ -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",
|
||||
|
||||
@ -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
|
||||
<MenuItem value={appScope.appCode}>{appScope.appCode}</MenuItem>
|
||||
)}
|
||||
</TextField>
|
||||
<TextField
|
||||
className="timezone-switch"
|
||||
inputProps={{ "aria-label": "显示时区" }}
|
||||
select
|
||||
size="small"
|
||||
value={timeZone}
|
||||
onChange={(event) => setTimeZone(event.target.value)}
|
||||
>
|
||||
{timeZoneOptions.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<button className="search-trigger" type="button" onClick={openSearch}>
|
||||
<Search fontSize="small" />
|
||||
<span>搜索菜单、用户、角色...</span>
|
||||
|
||||
@ -15,6 +15,7 @@ import MapOutlined from "@mui/icons-material/MapOutlined";
|
||||
import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
|
||||
import NotificationsNoneOutlined from "@mui/icons-material/NotificationsNoneOutlined";
|
||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||
import PushPinOutlined from "@mui/icons-material/PushPinOutlined";
|
||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||
import SendOutlined from "@mui/icons-material/SendOutlined";
|
||||
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
|
||||
@ -45,6 +46,7 @@ const iconMap = {
|
||||
menu: MenuOpenOutlined,
|
||||
notifications: NotificationsNoneOutlined,
|
||||
public: PublicOutlined,
|
||||
push_pin: PushPinOutlined,
|
||||
receipt: ReceiptLongOutlined,
|
||||
room: BedroomParentOutlined,
|
||||
send: SendOutlined,
|
||||
@ -85,7 +87,7 @@ export const fallbackNavigation = [
|
||||
icon: BedroomParentOutlined,
|
||||
id: "rooms",
|
||||
label: "房间管理",
|
||||
children: [routeNavItem("room-list", { icon: BedroomParentOutlined })],
|
||||
children: [routeNavItem("room-list", { icon: BedroomParentOutlined }), routeNavItem("room-pins", { icon: PushPinOutlined })],
|
||||
},
|
||||
{
|
||||
code: "app-config",
|
||||
|
||||
@ -25,7 +25,12 @@ export const PERMISSIONS = {
|
||||
coinSellerCreate: "coin-seller:create",
|
||||
coinSellerUpdate: "coin-seller:update",
|
||||
coinSellerStockCredit: "coin-seller:stock-credit",
|
||||
coinLedgerView: "coin-ledger:view",
|
||||
paymentBillView: "payment-bill:view",
|
||||
paymentProductView: "payment-product:view",
|
||||
paymentProductCreate: "payment-product:create",
|
||||
paymentProductUpdate: "payment-product:update",
|
||||
paymentProductDelete: "payment-product:delete",
|
||||
roleView: "role:view",
|
||||
roleCreate: "role:create",
|
||||
roleUpdate: "role:update",
|
||||
@ -57,8 +62,17 @@ export const PERMISSIONS = {
|
||||
roomView: "room:view",
|
||||
roomUpdate: "room:update",
|
||||
roomDelete: "room:delete",
|
||||
roomPinView: "room-pin:view",
|
||||
roomPinCreate: "room-pin:create",
|
||||
roomPinCancel: "room-pin:cancel",
|
||||
roomConfigView: "room-config:view",
|
||||
roomConfigUpdate: "room-config:update",
|
||||
appConfigView: "app-config:view",
|
||||
appConfigUpdate: "app-config:update",
|
||||
appVersionView: "app-version:view",
|
||||
appVersionCreate: "app-version:create",
|
||||
appVersionUpdate: "app-version:update",
|
||||
appVersionDelete: "app-version:delete",
|
||||
resourceView: "resource:view",
|
||||
resourceCreate: "resource:create",
|
||||
resourceUpdate: "resource:update",
|
||||
@ -75,6 +89,12 @@ export const PERMISSIONS = {
|
||||
dailyTaskCreate: "daily-task:create",
|
||||
dailyTaskUpdate: "daily-task:update",
|
||||
dailyTaskStatus: "daily-task:status",
|
||||
registrationRewardView: "registration-reward:view",
|
||||
registrationRewardUpdate: "registration-reward:update",
|
||||
gameView: "game:view",
|
||||
gameCreate: "game:create",
|
||||
gameUpdate: "game:update",
|
||||
gameStatus: "game:status",
|
||||
uploadCreate: "upload:create",
|
||||
jobView: "job:view",
|
||||
jobCancel: "job:cancel",
|
||||
@ -100,6 +120,7 @@ export const MENU_CODES = {
|
||||
appUserLoginLogs: "app-user-login-logs",
|
||||
rooms: "rooms",
|
||||
roomList: "room-list",
|
||||
roomPins: "room-pins",
|
||||
appConfig: "app-config",
|
||||
appConfigH5: "app-config-h5",
|
||||
appConfigBanners: "app-config-banners",
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { AppScopeProvider } from "@/app/app-scope/AppScopeProvider.jsx";
|
||||
import { AuthProvider } from "@/app/auth/AuthProvider.jsx";
|
||||
import { TimeZoneProvider } from "@/app/timezone/TimeZoneProvider.jsx";
|
||||
import { ConfirmProvider } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { QueryProvider } from "@/shared/query/QueryProvider.jsx";
|
||||
@ -15,7 +16,9 @@ export function AppProviders({ children }) {
|
||||
<ConfirmProvider>
|
||||
<RefreshProvider>
|
||||
<AuthProvider>
|
||||
<AppScopeProvider>{children}</AppScopeProvider>
|
||||
<AppScopeProvider>
|
||||
<TimeZoneProvider>{children}</TimeZoneProvider>
|
||||
</AppScopeProvider>
|
||||
</AuthProvider>
|
||||
</RefreshProvider>
|
||||
</ConfirmProvider>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
|
||||
import { PageLoadBoundary } from "@/shared/ui/PageLoadBoundary.jsx";
|
||||
|
||||
const pageCache = new Map();
|
||||
|
||||
@ -31,5 +32,9 @@ export function DeferredPage({ route }) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
return <Page {...(route.props || {})} />;
|
||||
return (
|
||||
<PageLoadBoundary pageKey={route.pageKey}>
|
||||
<Page {...(route.props || {})} />
|
||||
</PageLoadBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
47
src/app/timezone/TimeZoneProvider.jsx
Normal file
47
src/app/timezone/TimeZoneProvider.jsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
import {
|
||||
DEFAULT_TIME_ZONE,
|
||||
TIME_ZONE_OPTIONS,
|
||||
formatMillis,
|
||||
getTimeZoneLabel,
|
||||
readStoredTimeZone,
|
||||
setActiveTimeZone,
|
||||
writeStoredTimeZone
|
||||
} from "@/shared/utils/time.js";
|
||||
|
||||
const fallbackContext = {
|
||||
formatMillis: (value) => formatMillis(value, DEFAULT_TIME_ZONE),
|
||||
setTimeZone: () => {},
|
||||
timeZone: DEFAULT_TIME_ZONE,
|
||||
timeZoneLabel: getTimeZoneLabel(DEFAULT_TIME_ZONE),
|
||||
timeZoneOptions: TIME_ZONE_OPTIONS
|
||||
};
|
||||
|
||||
const TimeZoneContext = createContext(fallbackContext);
|
||||
|
||||
export function TimeZoneProvider({ children }) {
|
||||
const [timeZone, setTimeZoneState] = useState(() => setActiveTimeZone(readStoredTimeZone()));
|
||||
|
||||
const setTimeZone = useCallback((value) => {
|
||||
const normalized = setActiveTimeZone(value);
|
||||
writeStoredTimeZone(normalized);
|
||||
setTimeZoneState(normalized);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
formatMillis: (input) => formatMillis(input, timeZone),
|
||||
setTimeZone,
|
||||
timeZone,
|
||||
timeZoneLabel: getTimeZoneLabel(timeZone),
|
||||
timeZoneOptions: TIME_ZONE_OPTIONS
|
||||
}),
|
||||
[setTimeZone, timeZone]
|
||||
);
|
||||
|
||||
return <TimeZoneContext.Provider value={value}>{children}</TimeZoneContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTimeZone() {
|
||||
return useContext(TimeZoneContext);
|
||||
}
|
||||
@ -1,11 +1,14 @@
|
||||
import Refresh from "@mui/icons-material/Refresh";
|
||||
import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { PageHead } from "@/shared/ui/PageHead.jsx";
|
||||
import { TimeRangeSelect } from "@/shared/ui/TimeRangeSelect.jsx";
|
||||
|
||||
export function DashboardToolbar({ onRefresh, onRangeChange, overview, range }) {
|
||||
const { formatMillis } = useTimeZone();
|
||||
|
||||
return (
|
||||
<PageHead title="系统概览" meta={`刷新时间 ${overview?.updatedAt || "-"}`}>
|
||||
<PageHead title="系统概览" meta={`刷新时间 ${formatMillis(overview?.updatedAtMs ?? overview?.updatedAt)}`}>
|
||||
<TimeRangeSelect value={range} onChange={onRangeChange} />
|
||||
<Button onClick={onRefresh}>
|
||||
<Refresh fontSize="small" />
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
|
||||
export function LogTable({ data, isLogin, onQueryChange, query }) {
|
||||
const items = data.items || [];
|
||||
@ -27,7 +28,7 @@ export function LogTable({ data, isLogin, onQueryChange, query }) {
|
||||
key: "createdAt",
|
||||
label: "时间",
|
||||
width: "180px",
|
||||
render: (item) => <span className="muted">{formatDate(item.createdAt)}</span>
|
||||
render: (item) => <span className="muted">{formatMillis(item.createdAtMs ?? item.createdAt)}</span>
|
||||
}
|
||||
]
|
||||
: [
|
||||
@ -59,7 +60,7 @@ export function LogTable({ data, isLogin, onQueryChange, query }) {
|
||||
key: "createdAt",
|
||||
label: "时间",
|
||||
width: "180px",
|
||||
render: (item) => <span className="muted">{formatDate(item.createdAt)}</span>
|
||||
render: (item) => <span className="muted">{formatMillis(item.createdAtMs ?? item.createdAt)}</span>
|
||||
}
|
||||
];
|
||||
|
||||
@ -74,10 +75,3 @@ export function LogTable({ data, isLogin, onQueryChange, query }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
141
src/features/menus/components/MenuPermissionDetail.jsx
Normal file
141
src/features/menus/components/MenuPermissionDetail.jsx
Normal file
@ -0,0 +1,141 @@
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import VisibilityOffOutlined from "@mui/icons-material/VisibilityOffOutlined";
|
||||
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
|
||||
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
||||
import { kindLabel } from "@/features/menus/constants.js";
|
||||
|
||||
export function MenuPermissionDetail({
|
||||
abilities,
|
||||
accessPermissionQuery,
|
||||
accessPermissions,
|
||||
boundPermission,
|
||||
menu,
|
||||
onAccessPermissionQueryChange,
|
||||
onBindPermission,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onToggleVisible
|
||||
}) {
|
||||
if (!menu) {
|
||||
return (
|
||||
<section className="menu-detail-panel">
|
||||
<div className="empty-state menu-detail-empty">
|
||||
<div className="empty-state__title">当前无数据</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="menu-detail-panel">
|
||||
<div className="menu-detail-header">
|
||||
<div className="menu-detail-title">
|
||||
<span className={["menu-tree-status", menu.visible !== false ? "is-visible" : "is-hidden"].join(" ")}>
|
||||
{menu.visible !== false ? "显示" : "隐藏"}
|
||||
</span>
|
||||
<h2>{menu.label}</h2>
|
||||
<span>{menu.code}</span>
|
||||
</div>
|
||||
<AdminRowActions>
|
||||
{abilities.canUpdateMenu ? (
|
||||
<AdminActionIconButton label="编辑菜单" onClick={() => onEdit(menu)}>
|
||||
<EditOutlined fontSize="inherit" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{abilities.canVisibleMenu ? (
|
||||
<AdminActionIconButton label={menu.visible !== false ? "隐藏菜单" : "显示菜单"} onClick={() => onToggleVisible(menu)}>
|
||||
{menu.visible !== false ? <VisibilityOffOutlined fontSize="inherit" /> : <VisibilityOutlined fontSize="inherit" />}
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{abilities.canDeleteMenu ? (
|
||||
<AdminActionIconButton label="删除菜单" tone="danger" onClick={() => onRemove(menu)}>
|
||||
<DeleteOutlineOutlined fontSize="inherit" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</AdminRowActions>
|
||||
</div>
|
||||
|
||||
<div className="menu-detail-fields">
|
||||
<DetailField label="路由" value={menu.path || "-"} />
|
||||
<DetailField label="图标" value={menu.icon || "-"} />
|
||||
<DetailField label="父级 ID" value={menu.parentId || "-"} />
|
||||
<DetailField label="排序" value={menu.sort ?? "-"} />
|
||||
</div>
|
||||
|
||||
<section className="menu-access-card">
|
||||
<div className="menu-access-card__header">
|
||||
<div className="menu-panel-title">
|
||||
<h3>访问权限</h3>
|
||||
<span>{menu.permissionCode || "未绑定"}</span>
|
||||
</div>
|
||||
{menu.permissionCode ? (
|
||||
<Button disabled={!abilities.canUpdateMenu} onClick={() => onBindPermission(menu, "")}>
|
||||
清除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="menu-bound-permission">
|
||||
<span>{boundPermission?.name || menu.permissionCode || "未绑定"}</span>
|
||||
<code>{menu.permissionCode || "-"}</code>
|
||||
<span>{boundPermission ? kindLabel(boundPermission.kind) : "-"}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{abilities.canLoadPermissions ? (
|
||||
<section className="menu-permission-picker">
|
||||
<div className="menu-permission-picker__header">
|
||||
<div className="menu-panel-title">
|
||||
<h3>可绑定权限</h3>
|
||||
<span>{accessPermissions.length} 项</span>
|
||||
</div>
|
||||
<SearchBox
|
||||
placeholder="搜索访问权限"
|
||||
value={accessPermissionQuery}
|
||||
onChange={onAccessPermissionQueryChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="menu-permission-list">
|
||||
{accessPermissions.length ? (
|
||||
accessPermissions.map((permission) => {
|
||||
const selected = permission.code === menu.permissionCode;
|
||||
return (
|
||||
<button
|
||||
className={["menu-permission-option", selected ? "menu-permission-option--active" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
disabled={!abilities.canUpdateMenu || selected}
|
||||
key={permission.id}
|
||||
type="button"
|
||||
onClick={() => onBindPermission(menu, permission.code)}
|
||||
>
|
||||
<span>
|
||||
<strong>{permission.name}</strong>
|
||||
<code>{permission.code}</code>
|
||||
</span>
|
||||
<em>{kindLabel(permission.kind)}</em>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="empty-state empty-state--compact">
|
||||
<div className="empty-state__title">当前无数据</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({ label, value }) {
|
||||
return (
|
||||
<div className="menu-detail-field">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 ? <DragIndicatorOutlined className={adminListClasses.dragHandle} fontSize="small" /> : null
|
||||
},
|
||||
{
|
||||
key: "label",
|
||||
label: "菜单",
|
||||
width: "minmax(180px, 1.2fr)",
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索菜单、权限码",
|
||||
value: query,
|
||||
onChange: onQueryChange
|
||||
}),
|
||||
render: (menu) => <span style={{ paddingLeft: menu.depth * 18 }}>{menu.label}</span>
|
||||
},
|
||||
{ key: "path", label: "路由", width: "minmax(180px, 1.2fr)", render: (menu) => <span className="muted">{menu.path || "-"}</span> },
|
||||
{ key: "permissionCode", label: "权限码", width: "minmax(180px, 1.2fr)", render: (menu) => menu.permissionCode || "-" },
|
||||
{ key: "sort", label: "排序", width: "88px", render: (menu) => <span className="muted">{menu.sort}</span> },
|
||||
{ key: "visible", label: "状态", width: "100px", render: (menu) => (menu.visible ? "显示" : "隐藏") },
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(150px, 0.8fr)",
|
||||
render: (menu) => (
|
||||
<AdminRowActions>
|
||||
{abilities.canUpdateMenu ? (
|
||||
<AdminActionIconButton label="编辑" onClick={() => onEdit(menu)}>
|
||||
<EditOutlined fontSize="inherit" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{abilities.canVisibleMenu ? (
|
||||
<AdminActionIconButton label={menu.visible ? "隐藏" : "显示"} onClick={() => onToggleVisible(menu)}>
|
||||
{menu.visible ? <VisibilityOffOutlined fontSize="inherit" /> : <VisibilityOutlined fontSize="inherit" />}
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{abilities.canDeleteMenu ? (
|
||||
<AdminActionIconButton label="删除" tone="danger" onClick={() => onRemove(menu)}>
|
||||
<DeleteOutlineOutlined fontSize="inherit" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</AdminRowActions>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
getRowProps={(menu) => 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
123
src/features/menus/components/MenuTreePanel.jsx
Normal file
123
src/features/menus/components/MenuTreePanel.jsx
Normal file
@ -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 (
|
||||
<section className="menu-tree-panel">
|
||||
<div className="menu-tree-panel__header">
|
||||
<div className="menu-panel-title">
|
||||
<h2>菜单树</h2>
|
||||
<span>共 {total} 项</span>
|
||||
</div>
|
||||
<SearchBox placeholder="搜索菜单、路由、权限码" value={query} onChange={onQueryChange} />
|
||||
</div>
|
||||
<div className="menu-tree-list" role="tree">
|
||||
{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 (
|
||||
<button
|
||||
{...restRowProps}
|
||||
aria-selected={isSelected}
|
||||
className={[
|
||||
"menu-tree-item",
|
||||
isSelected ? "menu-tree-item--active" : "",
|
||||
className
|
||||
].filter(Boolean).join(" ")}
|
||||
key={menu.id || menu.code}
|
||||
role="treeitem"
|
||||
style={{ paddingLeft: `${16 + menu.depth * 18}px` }}
|
||||
type="button"
|
||||
onClick={() => onSelect(menu)}
|
||||
>
|
||||
<span className="menu-tree-item__drag">
|
||||
{abilities.canSortMenu ? <DragIndicatorOutlined fontSize="small" /> : null}
|
||||
</span>
|
||||
<span className="menu-tree-item__content">
|
||||
<span className="menu-tree-item__name">{menu.label}</span>
|
||||
<span className="menu-tree-item__meta">{menu.path || menu.code}</span>
|
||||
</span>
|
||||
<span className={["menu-tree-status", menu.visible !== false ? "is-visible" : "is-hidden"].join(" ")}>
|
||||
{menu.visible !== false ? "显示" : "隐藏"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="empty-state empty-state--compact">
|
||||
<div className="empty-state__title">当前无数据</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
}
|
||||
34
src/features/menus/components/PermissionLibraryDrawer.jsx
Normal file
34
src/features/menus/components/PermissionLibraryDrawer.jsx
Normal file
@ -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 (
|
||||
<SideDrawer
|
||||
className="permission-library-drawer"
|
||||
contentClassName="permission-library-drawer__body"
|
||||
open={open}
|
||||
title="权限库"
|
||||
onClose={onClose}
|
||||
>
|
||||
<SearchBox placeholder="搜索权限、权限码" value={query} onChange={onQueryChange} />
|
||||
<PermissionTable
|
||||
abilities={abilities}
|
||||
permissions={permissions}
|
||||
query={query}
|
||||
onEdit={onEdit}
|
||||
onQueryChange={onQueryChange}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</SideDrawer>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<AdminFilterResetButton disabled={!page.query} onClick={page.resetFilters} />
|
||||
<AdminFilterResetButton disabled={!page.hasActiveFilters} onClick={page.resetFilters} />
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
@ -31,6 +33,11 @@ export function MenuManagementPage() {
|
||||
<SyncOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canLoadPermissions ? (
|
||||
<AdminActionIconButton label="权限库" onClick={page.openPermissionLibrary}>
|
||||
<ShieldOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canCreatePermission ? (
|
||||
<AdminActionIconButton label="新增权限" onClick={page.openCreatePermission}>
|
||||
<Add fontSize="small" />
|
||||
@ -47,25 +54,29 @@ export function MenuManagementPage() {
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<section className="permission-page-grid">
|
||||
<MenuTable
|
||||
<section className="menu-permission-layout">
|
||||
<MenuTreePanel
|
||||
abilities={page.abilities}
|
||||
flatMenus={page.flatMenus}
|
||||
query={page.query}
|
||||
onEdit={page.openEditMenu}
|
||||
onQueryChange={page.changeQuery}
|
||||
menus={page.flatMenus}
|
||||
query={page.menuQuery}
|
||||
selectedMenuId={page.selectedMenu?.id}
|
||||
total={page.menuTotal}
|
||||
onQueryChange={page.changeMenuQuery}
|
||||
onReorder={page.reorderMenu}
|
||||
onSelect={page.selectMenu}
|
||||
/>
|
||||
<MenuPermissionDetail
|
||||
abilities={page.abilities}
|
||||
accessPermissionQuery={page.accessPermissionQuery}
|
||||
accessPermissions={page.accessPermissions}
|
||||
boundPermission={page.selectedPermission}
|
||||
menu={page.selectedMenu}
|
||||
onAccessPermissionQueryChange={page.changeAccessPermissionQuery}
|
||||
onBindPermission={page.bindMenuPermission}
|
||||
onEdit={page.openEditMenu}
|
||||
onRemove={page.removeMenu}
|
||||
onToggleVisible={page.toggleVisible}
|
||||
/>
|
||||
<PermissionTable
|
||||
abilities={page.abilities}
|
||||
query={page.query}
|
||||
onEdit={page.openEditPermission}
|
||||
onQueryChange={page.changeQuery}
|
||||
onRemove={page.removePermission}
|
||||
permissions={page.permissions}
|
||||
/>
|
||||
</section>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
@ -79,6 +90,16 @@ export function MenuManagementPage() {
|
||||
open={page.menuDrawerOpen}
|
||||
setForm={page.setMenuForm}
|
||||
/>
|
||||
<PermissionLibraryDrawer
|
||||
abilities={page.abilities}
|
||||
open={page.permissionLibraryOpen}
|
||||
permissions={page.permissions}
|
||||
query={page.permissionLibraryQuery}
|
||||
onClose={page.closePermissionLibrary}
|
||||
onEdit={page.openEditPermission}
|
||||
onQueryChange={page.changePermissionLibraryQuery}
|
||||
onRemove={page.removePermission}
|
||||
/>
|
||||
<PermissionFormDrawer
|
||||
editingPermission={page.editingPermission}
|
||||
form={page.permissionForm}
|
||||
|
||||
@ -2,6 +2,7 @@ import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import DoneAllOutlined from "@mui/icons-material/DoneAllOutlined";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { Card } from "@/shared/ui/Card.jsx";
|
||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||
|
||||
export function NotificationList({ abilities, items, onDelete, onRead }) {
|
||||
return (
|
||||
@ -12,16 +13,16 @@ export function NotificationList({ abilities, items, onDelete, onRead }) {
|
||||
<div>
|
||||
<div className="notification-title">{item.title}</div>
|
||||
<div className="notification-content">{item.content}</div>
|
||||
<div className="muted">{formatDate(item.createdAt)}</div>
|
||||
<TimeText className="muted" component="div" value={item.createdAtMs ?? item.createdAt} />
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
{!item.readAt && abilities.canRead ? (
|
||||
{!item.readAtMs && !item.readAt && abilities.canRead ? (
|
||||
<Button onClick={() => onRead(item.id)}>
|
||||
<DoneAllOutlined fontSize="small" />
|
||||
已读
|
||||
</Button>
|
||||
) : (
|
||||
<span className="muted">{item.readAt ? "已读" : "未读"}</span>
|
||||
<span className="muted">{item.readAtMs || item.readAt ? "已读" : "未读"}</span>
|
||||
)}
|
||||
{abilities.canDelete ? (
|
||||
<Button variant="danger" onClick={() => onDelete(item)}>
|
||||
@ -37,10 +38,3 @@ export function NotificationList({ abilities, items, onDelete, onRead }) {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@ import type {
|
||||
EntityId,
|
||||
PageQuery,
|
||||
RoomDto,
|
||||
RoomPinDto,
|
||||
RoomPinPayload,
|
||||
RoomUpdatePayload
|
||||
} from "@/shared/api/types";
|
||||
|
||||
@ -24,6 +26,29 @@ export function updateRoom(roomId: EntityId, payload: RoomUpdatePayload): Promis
|
||||
});
|
||||
}
|
||||
|
||||
export function listRoomPins(query: PageQuery = {}): Promise<ApiPage<RoomPinDto>> {
|
||||
const endpoint = API_ENDPOINTS.listRoomPins;
|
||||
return apiRequest<ApiPage<RoomPinDto>>(apiEndpointPath(API_OPERATIONS.listRoomPins), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
});
|
||||
}
|
||||
|
||||
export function createRoomPin(payload: RoomPinPayload): Promise<RoomPinDto> {
|
||||
const endpoint = API_ENDPOINTS.createRoomPin;
|
||||
return apiRequest<RoomPinDto, RoomPinPayload>(apiEndpointPath(API_OPERATIONS.createRoomPin), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelRoomPin(pinId: EntityId): Promise<RoomPinDto> {
|
||||
const endpoint = API_ENDPOINTS.cancelRoomPin;
|
||||
return apiRequest<RoomPinDto>(apiEndpointPath(API_OPERATIONS.cancelRoomPin, { pin_id: pinId }), {
|
||||
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 }), {
|
||||
|
||||
188
src/features/rooms/hooks/useRoomPinsPage.js
Normal file
188
src/features/rooms/hooks/useRoomPinsPage.js
Normal file
@ -0,0 +1,188 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { cancelRoomPin, createRoomPin, listRoomPins, listRooms } from "@/features/rooms/api";
|
||||
import { useRoomAbilities } from "@/features/rooms/permissions.js";
|
||||
import { roomPinCreateSchema } from "@/features/rooms/schema";
|
||||
|
||||
const pageSize = 10;
|
||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||
const emptyForm = () => ({ durationDays: "30", roomId: "", weight: "0" });
|
||||
|
||||
export function useRoomPinsPage() {
|
||||
const abilities = useRoomAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [status, setStatus] = useState("active");
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [roomSearch, setRoomSearch] = useState("");
|
||||
const [roomOptionsState, setRoomOptionsState] = useState({ error: "", loading: false, options: [] });
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
region_id: regionId,
|
||||
status,
|
||||
}),
|
||||
[query, regionId, status],
|
||||
);
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载房间置顶列表失败",
|
||||
fetcher: listRoomPins,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["room-pins", filters, page],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeAction !== "create") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(async () => {
|
||||
setRoomOptionsState((current) => ({ ...current, error: "", loading: true }));
|
||||
try {
|
||||
const data = await listRooms({
|
||||
keyword: roomSearch.trim(),
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
status: "active",
|
||||
});
|
||||
if (!cancelled) {
|
||||
setRoomOptionsState({ error: "", loading: false, options: data?.items || [] });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setRoomOptionsState({ error: err.message || "搜索房间失败", loading: false, options: [] });
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [activeAction, roomSearch]);
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeRegionId = (value) => {
|
||||
setRegionId(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value || "active");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setRegionId("");
|
||||
setStatus("active");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(emptyForm());
|
||||
setRoomSearch("");
|
||||
setRoomOptionsState({ error: "", loading: false, options: [] });
|
||||
setActiveAction("create");
|
||||
};
|
||||
|
||||
const closeAction = () => {
|
||||
setActiveAction("");
|
||||
};
|
||||
|
||||
const submitCreate = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("create", "房间已置顶", async () => {
|
||||
const payload = parseForm(roomPinCreateSchema, form);
|
||||
await createRoomPin(payload);
|
||||
closeAction();
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const cancelPin = async (pin) => {
|
||||
const room = pin.room || {};
|
||||
const ok = await confirm({
|
||||
confirmText: "取消置顶",
|
||||
message: room.title || room.roomShortId || room.roomId,
|
||||
title: "取消房间置顶",
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await runAction(`cancel-${pin.id}`, "房间置顶已取消", async () => {
|
||||
await cancelRoomPin(pin.id);
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const selectedRoom = roomOptionsState.options.find((room) => room.roomId === form.roomId) || null;
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
cancelPin,
|
||||
changeQuery,
|
||||
changeRegionId,
|
||||
changeStatus,
|
||||
closeAction,
|
||||
data,
|
||||
error,
|
||||
form,
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingRegions,
|
||||
openCreate,
|
||||
page,
|
||||
query,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
resetFilters,
|
||||
roomOptions: roomOptionsState.options,
|
||||
roomOptionsError: roomOptionsState.error,
|
||||
roomOptionsLoading: roomOptionsState.loading,
|
||||
roomSearch,
|
||||
selectedRoom,
|
||||
setForm,
|
||||
setPage,
|
||||
setRoomSearch,
|
||||
status,
|
||||
submitCreate,
|
||||
};
|
||||
}
|
||||
280
src/features/rooms/pages/RoomPinsPage.jsx
Normal file
280
src/features/rooms/pages/RoomPinsPage.jsx
Normal file
@ -0,0 +1,280 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import CloseOutlined from "@mui/icons-material/CloseOutlined";
|
||||
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
|
||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { AdminFormDialog } 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 { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { useRoomPinsPage } from "@/features/rooms/hooks/useRoomPinsPage.js";
|
||||
import styles from "@/features/rooms/rooms.module.css";
|
||||
|
||||
const pinStatusOptions = [
|
||||
{ label: "有效", value: "active" },
|
||||
{ label: "已过期", value: "expired" },
|
||||
{ label: "已取消", value: "cancelled" },
|
||||
{ label: "全部", value: "all" },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "room",
|
||||
label: "房间信息",
|
||||
width: "minmax(280px, 1.5fr)",
|
||||
render: (pin) => <PinRoomIdentity room={pin.room} />,
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
label: "用户信息",
|
||||
width: "minmax(240px, 1.2fr)",
|
||||
render: (pin) => <PinUserIdentity user={pin.user} />,
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
label: "区域",
|
||||
width: "minmax(140px, 0.7fr)",
|
||||
render: (pin) => pin.room?.regionName || (pin.room?.visibleRegionId ? `区域 ${pin.room.visibleRegionId}` : "-"),
|
||||
},
|
||||
{
|
||||
key: "weight",
|
||||
label: "权重",
|
||||
width: "minmax(100px, 0.5fr)",
|
||||
render: (pin) => Number(pin.weight || 0).toLocaleString("zh-CN"),
|
||||
},
|
||||
{
|
||||
key: "remaining",
|
||||
label: "剩余时间",
|
||||
width: "minmax(150px, 0.8fr)",
|
||||
render: (pin) => formatRemaining(pin),
|
||||
},
|
||||
{
|
||||
key: "expiresAt",
|
||||
label: "到期时间",
|
||||
width: "minmax(170px, 0.9fr)",
|
||||
render: (pin) => formatMillis(pin.expiresAtMs),
|
||||
},
|
||||
];
|
||||
|
||||
export function RoomPinsPage() {
|
||||
const page = useRoomPinsPage();
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const tableColumns = [
|
||||
...columns.map((column) =>
|
||||
column.key === "room"
|
||||
? {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索房间 ID",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
}
|
||||
: column.key === "region"
|
||||
? {
|
||||
...column,
|
||||
filter: createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId,
|
||||
}),
|
||||
}
|
||||
: column.key === "remaining"
|
||||
? {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: pinStatusOptions,
|
||||
placeholder: "置顶状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
}
|
||||
: column,
|
||||
),
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(104px, 0.6fr)",
|
||||
render: (pin) => <RoomPinActions page={page} pin={pin} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<div className={styles.toolbar}>
|
||||
<section className={styles.filters}>
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.query && page.status === "active" && !page.regionId}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
</section>
|
||||
{page.abilities.canPinCreate ? (
|
||||
<div className={styles.toolbarActions}>
|
||||
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={page.openCreate}>
|
||||
新增置顶
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
items={items}
|
||||
minWidth="1240px"
|
||||
rowKey={(pin) => pin.id}
|
||||
/>
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
|
||||
<AdminFormDialog
|
||||
loading={page.loadingAction === "create"}
|
||||
open={page.activeAction === "create"}
|
||||
size="compact"
|
||||
submitDisabled={!page.abilities.canPinCreate || page.loadingAction === "create"}
|
||||
title="新增房间置顶"
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitCreate}
|
||||
>
|
||||
<Autocomplete
|
||||
filterOptions={(options) => options}
|
||||
getOptionLabel={(option) => roomOptionLabel(option)}
|
||||
isOptionEqualToValue={(option, value) => option.roomId === value.roomId}
|
||||
loading={page.roomOptionsLoading}
|
||||
noOptionsText="当前无数据"
|
||||
options={page.roomOptions}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={Boolean(page.roomOptionsError)}
|
||||
helperText={page.roomOptionsError}
|
||||
label="选择房间"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} key={option.roomId}>
|
||||
<PinRoomIdentity room={option} />
|
||||
</li>
|
||||
)}
|
||||
size="small"
|
||||
value={page.selectedRoom}
|
||||
onChange={(_, option) => page.setForm({ ...page.form, roomId: option?.roomId || "" })}
|
||||
onInputChange={(_, value) => page.setRoomSearch(value)}
|
||||
/>
|
||||
<TextField
|
||||
label="权重"
|
||||
required
|
||||
type="number"
|
||||
value={page.form.weight}
|
||||
onChange={(event) => page.setForm({ ...page.form, weight: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="置顶时间(天)"
|
||||
required
|
||||
type="number"
|
||||
value={page.form.durationDays}
|
||||
onChange={(event) => page.setForm({ ...page.form, durationDays: event.target.value })}
|
||||
/>
|
||||
</AdminFormDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PinRoomIdentity({ room = {} }) {
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.cover}>
|
||||
{room.coverUrl ? <img src={room.coverUrl} alt="" /> : <MeetingRoomOutlined fontSize="small" />}
|
||||
</span>
|
||||
<div className={styles.identityText}>
|
||||
<div className={styles.name}>{room.title || "-"}</div>
|
||||
<div className={styles.meta}>长ID {room.roomId || "-"}</div>
|
||||
<div className={styles.meta}>短ID {room.roomShortId || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PinUserIdentity({ user = {} }) {
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.ownerAvatar}>
|
||||
{user.avatar ? <img src={user.avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
|
||||
</span>
|
||||
<div className={styles.identityText}>
|
||||
<div className={styles.name}>{user.username || "-"}</div>
|
||||
<div className={styles.meta}>长ID {user.userId || "-"}</div>
|
||||
<div className={styles.meta}>短ID {user.displayUserId || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomPinActions({ page, pin }) {
|
||||
const canCancel = page.abilities.canPinCancel && pin.status === "active" && Number(pin.remainingMs || 0) > 0;
|
||||
if (!canCancel) {
|
||||
return <span className={styles.meta}>-</span>;
|
||||
}
|
||||
return (
|
||||
<div className={styles.rowActions}>
|
||||
<Tooltip arrow title="取消置顶">
|
||||
<span>
|
||||
<IconButton
|
||||
disabled={page.loadingAction === `cancel-${pin.id}`}
|
||||
label="取消置顶"
|
||||
tone="danger"
|
||||
onClick={() => page.cancelPin(pin)}
|
||||
>
|
||||
<CloseOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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)}小时`;
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
];
|
||||
|
||||
@ -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<typeof roomUpdateSchema>;
|
||||
export type RoomStatusForm = z.infer<typeof roomStatusSchema>;
|
||||
export type RoomPinCreateForm = z.infer<typeof roomPinCreateSchema>;
|
||||
|
||||
@ -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 }) {
|
||||
<ProfileRow label="团队" value={user.team} />
|
||||
<ProfileRow label="状态" value={<UserStatusBadge status={user.status} />} />
|
||||
<ProfileRow label="MFA" value={formatMfa(user)} />
|
||||
<ProfileRow label="最后登录" value={user.lastLogin} />
|
||||
<ProfileRow label="最后登录" value={<TimeText value={user.lastLoginMs ?? user.lastLoginAtMs ?? user.lastLogin} />} />
|
||||
</div>
|
||||
</div>
|
||||
</SideDrawer>
|
||||
|
||||
@ -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) => <UserStatusBadge status={user.status} />
|
||||
},
|
||||
{ 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: "操作",
|
||||
|
||||
@ -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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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,
|
||||
|
||||
572
src/shared/api/generated/schema.d.ts
vendored
572
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 (
|
||||
<div className="data-state data-state--loading">
|
||||
|
||||
@ -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({
|
||||
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} key={rowKey(item)} {...restRowProps}>
|
||||
{preparedColumns.map((column) => (
|
||||
<div className={cellClassName(column)} key={column.key}>
|
||||
{column.render ? column.render(item, index, context) : displayValue(item[column.key])}
|
||||
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
48
src/shared/ui/DataTable.timezone.test.jsx
Normal file
48
src/shared/ui/DataTable.timezone.test.jsx
Normal file
@ -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(
|
||||
<TimeZoneProvider>
|
||||
<TimezoneTableFixture />
|
||||
</TimeZoneProvider>
|
||||
);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<button type="button" onClick={() => setTimeZone("UTC")}>
|
||||
UTC
|
||||
</button>
|
||||
<DataTable columns={columns} items={[{ createdAtMs: epochMillis, id: 1 }]} rowKey={(item) => item.id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
80
src/shared/ui/PageLoadBoundary.jsx
Normal file
80
src/shared/ui/PageLoadBoundary.jsx
Normal file
@ -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 (
|
||||
<PageLoadContext.Provider value={contextValue}>
|
||||
<div className="page-load-boundary">
|
||||
{showSkeleton ? <PageSkeleton /> : null}
|
||||
<div
|
||||
aria-hidden={showSkeleton ? "true" : undefined}
|
||||
className={["page-load-boundary__content", showSkeleton ? "page-load-boundary__content--hidden" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</PageLoadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
12
src/shared/ui/TimeText.jsx
Normal file
12
src/shared/ui/TimeText.jsx
Normal file
@ -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 (
|
||||
<Component className={className} title={text === "-" ? undefined : timeZoneLabel}>
|
||||
{text}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
21
src/shared/utils/time.test.js
Normal file
21
src/shared/utils/time.test.js
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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));
|
||||
|
||||
@ -69,6 +69,10 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timezone-switch {
|
||||
flex-basis: 112px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user