更新后台管理功能与菜单权限布局

This commit is contained in:
ZuoZuo 2026-05-14 00:30:29 +08:00
parent dd8cd5d952
commit dba61e808a
38 changed files with 2979 additions and 204 deletions

View File

@ -84,6 +84,46 @@
]
}
},
"/admin/activity/registration-reward/claims": {
"get": {
"operationId": "listRegistrationRewardClaims",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "registration-reward:view",
"x-permissions": [
"registration-reward:view"
]
}
},
"/admin/activity/registration-reward/config": {
"get": {
"operationId": "getRegistrationRewardConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "registration-reward:view",
"x-permissions": [
"registration-reward:view"
]
},
"put": {
"operationId": "updateRegistrationRewardConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "registration-reward:update",
"x-permissions": [
"registration-reward:update"
]
}
},
"/admin/agencies": {
"get": {
"operationId": "listAgencies",
@ -276,6 +316,78 @@
]
}
},
"/admin/app-config/versions": {
"get": {
"operationId": "listAppVersions",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "app-version:view",
"x-permissions": [
"app-version:view"
]
},
"post": {
"operationId": "createAppVersion",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "app-version:create",
"x-permissions": [
"app-version:create"
]
}
},
"/admin/app-config/versions/{version_id}": {
"put": {
"operationId": "updateAppVersion",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "version_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "app-version:update",
"x-permissions": [
"app-version:update"
]
},
"delete": {
"operationId": "deleteAppVersion",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "version_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "app-version:delete",
"x-permissions": [
"app-version:delete"
]
}
},
"/admin/apps": {
"get": {
"operationId": "listApps",
@ -691,6 +803,130 @@
]
}
},
"/admin/game/games": {
"get": {
"operationId": "listCatalog",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "game:view",
"x-permissions": [
"game:view"
]
},
"post": {
"operationId": "createCatalog",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "game:create",
"x-permissions": [
"game:create"
]
}
},
"/admin/game/games/{game_id}": {
"patch": {
"operationId": "updateCatalog",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "game_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "game:update",
"x-permissions": [
"game:update"
]
}
},
"/admin/game/games/{game_id}/status": {
"patch": {
"operationId": "setGameStatus",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "game_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "game:status",
"x-permissions": [
"game:status"
]
}
},
"/admin/game/platforms": {
"get": {
"operationId": "listPlatforms",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "game:view",
"x-permissions": [
"game:view"
]
},
"post": {
"operationId": "createPlatform",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "game:update",
"x-permissions": [
"game:update"
]
}
},
"/admin/game/platforms/{platform_code}": {
"patch": {
"operationId": "updatePlatform",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "platform_code",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "game:update",
"x-permissions": [
"game:update"
]
}
},
"/admin/gifts": {
"get": {
"operationId": "listGifts",
@ -823,6 +1059,20 @@
]
}
},
"/admin/operations/coin-ledger": {
"get": {
"operationId": "listCoinLedger",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "coin-ledger:view",
"x-permissions": [
"coin-ledger:view"
]
}
},
"/admin/payment/recharge-bills": {
"get": {
"operationId": "listRechargeBills",
@ -837,6 +1087,78 @@
]
}
},
"/admin/payment/recharge-products": {
"get": {
"operationId": "listRechargeProducts",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "payment-product:view",
"x-permissions": [
"payment-product:view"
]
},
"post": {
"operationId": "createRechargeProduct",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "payment-product:create",
"x-permissions": [
"payment-product:create"
]
}
},
"/admin/payment/recharge-products/{product_id}": {
"put": {
"operationId": "updateRechargeProduct",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "product_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "payment-product:update",
"x-permissions": [
"payment-product:update"
]
},
"delete": {
"operationId": "deleteRechargeProduct",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "product_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "payment-product:delete",
"x-permissions": [
"payment-product:delete"
]
}
},
"/admin/regions": {
"get": {
"operationId": "listRegions",
@ -1345,6 +1667,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",

View File

@ -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>

View File

@ -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",

View File

@ -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",

View File

@ -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>

View File

@ -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>
);
}

View 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);
}

View File

@ -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" />

View File

@ -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 });
}

View 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>
);
}

View File

@ -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();
}
};
}

View 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();
}
};
}

View 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>
);
}

View File

@ -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,
};
}

View File

@ -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;
}
}

View File

@ -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}

View File

@ -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 });
}

View File

@ -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 }), {

View 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,
};
}

View 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)}小时`;
}

View File

@ -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)

View File

@ -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;
}

View File

@ -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
}
];

View File

@ -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>;

View File

@ -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>

View File

@ -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: "操作",

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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">

View File

@ -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>

View 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} />
</>
);
}

View 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]);
}

View 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>
);
}

View File

@ -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);
}

View 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");
});
});

View File

@ -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));

View File

@ -69,6 +69,10 @@
display: none;
}
.timezone-switch {
flex-basis: 112px;
}
.workspace {
padding: 16px;
}