diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index e8a0230..7ecd58b 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -1693,6 +1693,56 @@ ] } }, + "/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", diff --git a/src/app/layout/Header.jsx b/src/app/layout/Header.jsx index a566c09..36d09d9 100644 --- a/src/app/layout/Header.jsx +++ b/src/app/layout/Header.jsx @@ -14,6 +14,7 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx"; import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx"; import { changePassword } from "@/features/auth/api"; import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx"; @@ -34,6 +35,7 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr const { logout, user } = useAuth(); const appScope = useAppScope(); const { showToast } = useToast(); + const { setTimeZone, timeZone, timeZoneOptions } = useTimeZone(); const ToggleIcon = isSidebarCollapsed ? MenuOpen : Menu; const userMenuOpen = Boolean(userMenuAnchor); @@ -168,6 +170,20 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr {appScope.appCode} )} + setTimeZone(event.target.value)} + > + {timeZoneOptions.map((option) => ( + + {option.label} + + ))} + + ) : null} + +
+ {boundPermission?.name || menu.permissionCode || "未绑定"} + {menu.permissionCode || "-"} + {boundPermission ? kindLabel(boundPermission.kind) : "-"} +
+ + + {abilities.canLoadPermissions ? ( +
+
+
+

可绑定权限

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

菜单树

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