Browse Source

feat: 角色管理页面UI与功能优化,支持列设置、密度切换、搜索体验提升等

web
hyh 3 months ago
parent
commit
14bc6be277
  1. 933
      src/CellularManagement.WebUI/package-lock.json
  2. 30
      src/CellularManagement.WebUI/package.json
  3. 14
      src/CellularManagement.WebUI/src/App.tsx
  4. 25
      src/CellularManagement.WebUI/src/components/layout/Header.tsx
  5. 6
      src/CellularManagement.WebUI/src/components/layout/Tabs.tsx
  6. 56
      src/CellularManagement.WebUI/src/components/ui/PaginationBar.tsx
  7. 12
      src/CellularManagement.WebUI/src/components/ui/SidebarMenuItem.tsx
  8. 4
      src/CellularManagement.WebUI/src/components/ui/SidebarToggleButton.tsx
  9. 208
      src/CellularManagement.WebUI/src/components/ui/TableToolbar.tsx
  10. 20
      src/CellularManagement.WebUI/src/components/ui/ThemeToggle.tsx
  11. 28
      src/CellularManagement.WebUI/src/components/ui/button.tsx
  12. 13
      src/CellularManagement.WebUI/src/components/ui/index.ts
  13. 115
      src/CellularManagement.WebUI/src/components/ui/select.tsx
  14. 20
      src/CellularManagement.WebUI/src/components/ui/theme-toggle.tsx
  15. 6
      src/CellularManagement.WebUI/src/contexts/AuthContext.tsx
  16. 35
      src/CellularManagement.WebUI/src/contexts/SettingsContext.tsx
  17. 47
      src/CellularManagement.WebUI/src/contexts/ThemeContext.tsx
  18. 34
      src/CellularManagement.WebUI/src/contexts/ThemeProvider.tsx
  19. 24
      src/CellularManagement.WebUI/src/pages/roles/RoleForm.tsx
  20. 132
      src/CellularManagement.WebUI/src/pages/roles/RoleTable.tsx
  21. 114
      src/CellularManagement.WebUI/src/pages/roles/RolesView.tsx
  22. 19
      src/CellularManagement.WebUI/src/providers/ThemeProvider.tsx
  23. 12
      src/CellularManagement.WebUI/src/stores/appState.ts
  24. 3
      src/CellularManagement.WebUI/src/stores/index.ts
  25. 18
      src/CellularManagement.WebUI/src/stores/settingsStore.ts
  26. 12
      src/CellularManagement.WebUI/src/stores/themeStore.ts
  27. 15
      src/CellularManagement.WebUI/src/stores/userStore.ts

933
src/CellularManagement.WebUI/package-lock.json

File diff suppressed because it is too large

30
src/CellularManagement.WebUI/package.json

@ -10,12 +10,32 @@
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-accordion": "^1.2.10",
"@radix-ui/react-alert-dialog": "^1.1.13",
"@radix-ui/react-aspect-ratio": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-collapsible": "^1.1.10",
"@radix-ui/react-context-menu": "^2.2.14",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-menubar": "^1.1.14",
"@radix-ui/react-navigation-menu": "^1.2.12",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-progress": "^1.1.6",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slider": "^1.3.4",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toast": "^1.2.13",
"@radix-ui/react-toggle": "^1.1.8",
"@radix-ui/react-tooltip": "^1.2.6",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",

14
src/CellularManagement.WebUI/src/App.tsx

@ -2,16 +2,22 @@ import { BrowserRouter } from 'react-router-dom';
import { RecoilRoot } from 'recoil';
import { AppRouter } from './routes/AppRouter';
import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './providers/ThemeProvider';
import { ThemeContextProvider } from './contexts/ThemeContext';
import { ThemeProvider } from './contexts/ThemeProvider';
import { SettingsContextProvider } from './contexts/SettingsContext';
export function App() {
return (
<RecoilRoot>
<BrowserRouter>
<AuthProvider>
<ThemeProvider>
<AppRouter />
</ThemeProvider>
<ThemeContextProvider>
<ThemeProvider>
<SettingsContextProvider>
<AppRouter />
</SettingsContextProvider>
</ThemeProvider>
</ThemeContextProvider>
</AuthProvider>
</BrowserRouter>
</RecoilRoot>

25
src/CellularManagement.WebUI/src/components/layout/Header.tsx

@ -6,12 +6,16 @@ import { useSidebarToggle } from '@/hooks/useSidebarToggle';
import { SidebarToggleButton } from '@/components/ui/SidebarToggleButton';
import { useAuth } from '@/contexts/AuthContext';
import { getUserInfo } from '@/utils/getUserInfo';
import { ThemeToggle } from '@/components/ui/ThemeToggle';
import { useTheme } from '@/contexts/ThemeContext';
import { cn } from '@/lib/utils';
export function Header() {
const { isOpen: isNotificationOpen, toggle: toggleNotification } = useDrawer();
const { isCollapsed, toggleSidebar } = useSidebarToggle();
const { user, isLoading } = useAuth();
const { userName, email } = getUserInfo(user);
const { theme } = useTheme();
console.log('[Header] 用户信息', userName, email);
return (
<header
@ -19,10 +23,18 @@ export function Header() {
>
<div className="flex items-center h-full">
<SidebarToggleButton onClick={toggleSidebar} isCollapsed={isCollapsed} />
<img src="/logo.svg" alt="Logo" className="h-10 w-10 ml-2" />
<span className="ml-2 text-2xl font-bold tracking-wide"></span>
<img
src="/logo.svg"
alt="Logo"
className={cn(
"h-10 w-10 ml-2 transition-all duration-300",
theme === 'dark' ? "invert brightness-0" : ""
)}
/>
<span className="ml-2 text-2xl font-bold tracking-wide text-foreground"></span>
</div>
<div className="flex items-center gap-4">
<ThemeToggle />
<button
onClick={toggleNotification}
className="relative rounded-full p-2 hover:bg-accent"
@ -47,9 +59,12 @@ export function Header() {
</svg>
</button>
<SearchInput />
<UserAvatarMenu
username={isLoading ? '加载中...' : userName}
email={isLoading ? '' : email}
<UserAvatarMenu
username={userName}
email={email}
onProfile={() => {}}
onPassword={() => {}}
onLogout={() => {}}
/>
</div>
<NotificationDrawer isOpen={isNotificationOpen} onClose={toggleNotification} />

6
src/CellularManagement.WebUI/src/components/layout/Tabs.tsx

@ -41,7 +41,7 @@ export function TabActionsMenu({ open, onClose, onAction, anchorRef }: TabAction
<div
ref={menuRef}
className={cn(
"absolute right-0 mt-2 w-44 rounded-xl shadow-2xl bg-white border z-50 transition-all duration-200",
"absolute right-0 mt-2 w-44 rounded-xl shadow-2xl bg-background border z-50 transition-all duration-200",
open ? "opacity-100 scale-100" : "opacity-0 scale-95 pointer-events-none"
)}
style={{ minWidth: 180 }}
@ -67,7 +67,7 @@ export function TabActionsMenu({ open, onClose, onAction, anchorRef }: TabAction
</div>
</div>
<div className="border-t my-1" />
<div className="py-2">
<div>
<div
className="flex items-center px-4 py-2 hover:bg-accent cursor-pointer text-sm text-destructive transition-colors"
onClick={() => onAction('others')}
@ -205,7 +205,7 @@ export function Tabs() {
>
<button
className={cn(
"ml-2 w-8 h-8 flex items-center justify-center rounded-full bg-white shadow hover:bg-accent transition-colors",
"ml-2 w-8 h-8 flex items-center justify-center rounded-full bg-background border shadow hover:bg-accent transition-colors",
menuOpen ? "bg-accent shadow-lg" : ""
)}
aria-label="更多操作"

56
src/CellularManagement.WebUI/src/components/ui/PaginationBar.tsx

@ -0,0 +1,56 @@
import React from 'react';
interface PaginationBarProps {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
showSummary?: boolean;
pageSizeOptions?: number[];
}
export default function PaginationBar({
page,
pageSize,
total,
onPageChange,
onPageSizeChange,
showSummary = true,
pageSizeOptions = [10, 20, 50],
}: PaginationBarProps) {
const totalPages = Math.ceil(total / pageSize);
const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
const end = Math.min(page * pageSize, total);
return (
<div className="flex justify-end items-center gap-1 text-xs h-8 mt-2 border-t pt-2">
{showSummary && (
<div className="text-muted-foreground">
{start}-{end} / {total}
</div>
)}
<button
className="border rounded px-1 py-0.5 text-xs disabled:opacity-50"
disabled={page === 1}
onClick={() => onPageChange(page - 1)}
></button>
<span className="text-xs mx-1">{page} / {totalPages}</span>
<button
className="border rounded px-1 py-0.5 text-xs disabled:opacity-50"
disabled={page === totalPages || totalPages === 0}
onClick={() => onPageChange(page + 1)}
></button>
<select
className="border rounded px-1 py-0.5 text-xs bg-background text-foreground ml-2"
value={pageSize}
onChange={e => onPageSizeChange(Number(e.target.value))}
style={{ minWidth: 60 }}
>
{pageSizeOptions.map(opt => (
<option key={opt} value={opt}>{opt} /</option>
))}
</select>
</div>
);
}

12
src/CellularManagement.WebUI/src/components/ui/SidebarMenuItem.tsx

@ -62,7 +62,7 @@ export const SidebarMenuItem = memo(function SidebarMenuItem({ item, isCollapsed
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all hover:bg-accent',
isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground',
isActive ? 'bg-accent text-accent-foreground' : 'text-foreground',
isCollapsed && 'justify-center'
)
}
@ -85,7 +85,7 @@ export const SidebarMenuItem = memo(function SidebarMenuItem({ item, isCollapsed
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-all hover:bg-accent',
isChildActive ? 'bg-accent/50 text-accent-foreground' : 'text-muted-foreground',
isChildActive ? 'bg-accent/50 text-accent-foreground' : 'text-foreground',
isCollapsed && 'justify-center'
)}
>
@ -105,7 +105,7 @@ export const SidebarMenuItem = memo(function SidebarMenuItem({ item, isCollapsed
{/* 折叠时悬浮展开二级菜单,使用Portal */}
{isCollapsed && isOpen && createPortal(
<div
className="z-50 min-w-[140px] bg-white shadow-lg rounded-lg py-2 fixed"
className="z-50 min-w-[140px] bg-background border shadow-lg rounded-lg py-2 fixed"
style={{ left: menuPos.left, top: menuPos.top }}
>
{item.children.map((child) => (
@ -115,7 +115,7 @@ export const SidebarMenuItem = memo(function SidebarMenuItem({ item, isCollapsed
className={({ isActive }) =>
cn(
'block px-4 py-2 text-sm whitespace-nowrap rounded hover:bg-accent',
isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'
isActive ? 'bg-accent text-accent-foreground' : 'text-foreground'
)
}
>
@ -125,7 +125,7 @@ export const SidebarMenuItem = memo(function SidebarMenuItem({ item, isCollapsed
</div>,
document.body
)}
{/* 非折叠时的二级菜单,保持原有逻辑 */}
{/* 非折叠时的二级菜单 */}
{!isCollapsed && (
<div
className={cn(
@ -142,7 +142,7 @@ export const SidebarMenuItem = memo(function SidebarMenuItem({ item, isCollapsed
className={({ isActive }) =>
cn(
'flex items-center rounded-lg px-3 py-2 text-sm transition-all hover:bg-accent',
isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'
isActive ? 'bg-accent text-accent-foreground' : 'text-foreground'
)
}
>

4
src/CellularManagement.WebUI/src/components/ui/SidebarToggleButton.tsx

@ -14,13 +14,13 @@ export function SidebarToggleButton({ onClick, isCollapsed }: SidebarToggleButto
className={cn(
'flex items-center justify-center w-10 h-10 rounded-full',
'transition-all duration-200',
'hover:bg-gray-100',
'hover:bg-accent',
'focus:outline-none',
'focus:bg-transparent active:bg-transparent'
)}
aria-label={isCollapsed ? '展开侧边栏' : '折叠侧边栏'}
>
<MdSyncAlt className="w-5 h-5 text-gray-700" />
<MdSyncAlt className="w-5 h-5 text-foreground" />
</button>
</div>
);

208
src/CellularManagement.WebUI/src/components/ui/TableToolbar.tsx

@ -0,0 +1,208 @@
import React, { useState, useRef, useEffect } from 'react';
import { AiOutlineReload, AiOutlineSetting, AiOutlineTable } from 'react-icons/ai';
interface ColumnConfig {
key: string;
title: string;
visible: boolean;
fixed?: boolean;
}
interface TableToolbarProps {
onRefresh?: () => void;
onDensityChange?: (density: DensityType) => void;
onColumnsChange?: (columns: ColumnConfig[]) => void;
onColumnsReset?: () => void;
onColumnsConfigOpen?: () => void;
columns?: ColumnConfig[];
density?: DensityType;
}
export type DensityType = 'relaxed' | 'default' | 'compact';
const densityOptions = [
{ value: 'relaxed', label: '宽松' },
{ value: 'default', label: '中等' },
{ value: 'compact', label: '紧凑' },
];
function Tooltip({ text, children }: { text: string; children: React.ReactNode }) {
const [show, setShow] = useState(false);
return (
<div className="relative flex items-center">
<div
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
className="flex"
>
{children}
</div>
{show && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 flex flex-col items-center">
<div className="bg-black text-white text-xs rounded px-2 py-1 shadow-lg whitespace-nowrap font-bold relative">
{text}
<span className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-x-8 border-x-transparent border-t-8 border-t-black"></span>
</div>
</div>
)}
</div>
);
}
export default function TableToolbar({ onRefresh, onDensityChange, onColumnsChange, onColumnsReset, columns = [], density = 'default' }: TableToolbarProps) {
const [densityMenuOpen, setDensityMenuOpen] = useState(false);
const [columnsMenuOpen, setColumnsMenuOpen] = useState(false);
const densityBtnRef = useRef<HTMLDivElement>(null);
const columnsBtnRef = useRef<HTMLDivElement>(null);
const [localColumns, setLocalColumns] = useState<ColumnConfig[]>(columns);
const dragItem = useRef<number | null>(null);
const dragOverItem = useRef<number | null>(null);
// 同步外部columns变化
useEffect(() => {
setLocalColumns(columns);
}, [columns]);
// 点击外部关闭菜单
useEffect(() => {
function handleClick(e: MouseEvent) {
if (densityBtnRef.current && !densityBtnRef.current.contains(e.target as Node)) {
setDensityMenuOpen(false);
}
if (columnsBtnRef.current && !columnsBtnRef.current.contains(e.target as Node)) {
setColumnsMenuOpen(false);
}
}
if (densityMenuOpen || columnsMenuOpen) document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [densityMenuOpen, columnsMenuOpen]);
// 切换列显示
const handleToggleColumn = (key: string) => {
const updated = localColumns.map(col => col.key === key ? { ...col, visible: !col.visible } : col);
setLocalColumns(updated);
onColumnsChange?.(updated);
};
// 重置
const handleReset = () => {
onColumnsReset?.();
setColumnsMenuOpen(false);
};
// 拖拽排序
const handleDragStart = (index: number) => {
dragItem.current = index;
};
const handleDragEnter = (index: number) => {
dragOverItem.current = index;
};
const handleDragEnd = () => {
if (dragItem.current === null || dragOverItem.current === null || dragItem.current === dragOverItem.current) {
dragItem.current = null;
dragOverItem.current = null;
return;
}
const updated = [...localColumns];
const [removed] = updated.splice(dragItem.current, 1);
updated.splice(dragOverItem.current, 0, removed);
setLocalColumns(updated);
onColumnsChange?.(updated);
dragItem.current = null;
dragOverItem.current = null;
};
return (
<div className="flex items-center gap-3">
<Tooltip text="刷新">
<button
type="button"
className="p-2 rounded-full hover:bg-accent transition-colors"
onClick={onRefresh}
>
<AiOutlineReload className="w-5 h-5" />
</button>
</Tooltip>
{/* 密度设置按钮及菜单 */}
<div className="relative" ref={densityBtnRef}>
<Tooltip text="密度">
<button
type="button"
aria-label="密度"
className={
`p-2 rounded-full border-b-2 transition-colors border-transparent hover:bg-accent !bg-transparent focus:!bg-transparent active:!bg-transparent`
+ (densityMenuOpen ? ' text-primary border-primary' : '')
}
style={{
background: 'transparent',
boxShadow: 'none',
}}
onClick={() => setDensityMenuOpen(v => !v)}
>
<span className="sr-only"></span>
<AiOutlineSetting className="w-5 h-5" />
</button>
</Tooltip>
{densityMenuOpen && (
<div className="absolute right-0 mt-2 w-20 rounded-xl shadow-2xl bg-background border z-50 animate-fade-in">
{densityOptions.map(opt => (
<div
key={opt.value}
className={`px-4 py-2 cursor-pointer text-center text-sm select-none transition-colors ${density === opt.value ? 'bg-primary/10 text-primary font-bold' : 'hover:bg-accent'}`}
onClick={() => { onDensityChange?.(opt.value as DensityType); setDensityMenuOpen(false); }}
>
{opt.label}
</div>
))}
</div>
)}
</div>
{/* 列设置按钮及菜单 */}
<div className="relative" ref={columnsBtnRef}>
<Tooltip text="列显示设置">
<button
type="button"
className="p-2 rounded-full hover:bg-accent transition-colors"
onClick={() => setColumnsMenuOpen(v => !v)}
>
<AiOutlineTable className="w-5 h-5" />
</button>
</Tooltip>
{columnsMenuOpen && (
<div className="absolute right-0 mt-2 w-56 rounded-xl shadow-2xl bg-background border z-50 animate-fade-in p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 font-bold text-primary">
<input type="checkbox" checked readOnly className="accent-primary" />
<span></span>
</div>
<button className="text-primary font-bold text-sm hover:underline" onClick={handleReset}></button>
</div>
<div className="space-y-2">
{localColumns.map((col, idx) => (
<div
key={col.key}
className="flex items-center gap-2 pl-2"
draggable
onDragStart={() => handleDragStart(idx)}
onDragEnter={() => handleDragEnter(idx)}
onDragEnd={handleDragEnd}
onDragOver={e => e.preventDefault()}
style={{ cursor: 'move' }}
>
<span className="cursor-move select-none text-gray-400"></span>
<input
type="checkbox"
checked={col.visible}
onChange={() => handleToggleColumn(col.key)}
className="accent-primary"
/>
<span>{col.title}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

20
src/CellularManagement.WebUI/src/components/ui/ThemeToggle.tsx

@ -0,0 +1,20 @@
import { Moon, Sun } from 'lucide-react';
import { useTheme } from '@/contexts/ThemeContext';
import { Button } from './button';
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="relative rounded-full p-2 hover:bg-accent"
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only"></span>
</Button>
);
}

28
src/CellularManagement.WebUI/src/components/ui/button.tsx

@ -4,30 +4,32 @@ import { cn } from '@/lib/utils';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
variant?: 'default' | 'destructive' | 'outline' | 'secondary';
size?: 'default' | 'sm' | 'lg';
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost';
size?: 'default' | 'sm' | 'lg' | 'icon';
}
const buttonVariants = {
default: 'bg-blue-600 text-white hover:bg-blue-700',
destructive: 'bg-red-600 text-white hover:bg-red-700',
outline: 'border border-gray-300 text-gray-700 bg-white hover:bg-gray-50',
secondary: 'bg-gray-100 text-gray-800 hover:bg-gray-200',
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
};
const sizeVariants = {
default: 'px-4 py-2 text-sm',
sm: 'px-3 py-1 text-xs',
lg: 'px-6 py-3 text-base',
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-9 w-9',
};
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(
'inline-flex items-center justify-center rounded font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none',
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
buttonVariants[variant],
sizeVariants[size],
className
@ -38,4 +40,6 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
);
}
);
Button.displayName = 'Button';
Button.displayName = 'Button';
export { Button };

13
src/CellularManagement.WebUI/src/components/ui/index.ts

@ -1,6 +1,7 @@
export * from './button';
export * from './input';
export * from './form';
export * from './label';
export * from './table';
export * from './dialog';
export { Button } from './button';
export { Input } from './input';
export { Form } from './form';
export { Label } from './label';
export { Table } from './table';
export { Dialog } from './dialog';
export { ThemeToggle } from './theme-toggle';

115
src/CellularManagement.WebUI/src/components/ui/select.tsx

@ -0,0 +1,115 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
};

20
src/CellularManagement.WebUI/src/components/ui/theme-toggle.tsx

@ -0,0 +1,20 @@
import { Moon, Sun } from 'lucide-react';
import { Button } from './button';
import { useTheme } from '@/contexts/ThemeContext';
export function ThemeToggle() {
const { toggleTheme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="h-9 w-9"
>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only"></span>
</Button>
);
}

6
src/CellularManagement.WebUI/src/contexts/AuthContext.tsx

@ -1,7 +1,7 @@
import { createContext, useContext, useReducer, ReactNode, useMemo, useEffect } from 'react';
import { createContext, useContext, useReducer, ReactNode, useMemo, useEffect, useState } from 'react';
import { AuthState, AuthContextType, LoginRequest, User } from '@/types/auth';
import { useSetRecoilState } from 'recoil';
import { userState } from '@/states/appState';
import { useSetRecoilState, useRecoilState } from 'recoil';
import { userState } from '@/stores/userStore';
import { authService } from '@/services/authService';
import { useAuthSync } from '@/hooks/useAuthSync';
import { useAuthInit } from '@/hooks/useAuthInit';

35
src/CellularManagement.WebUI/src/contexts/SettingsContext.tsx

@ -0,0 +1,35 @@
import { createContext, useContext } from 'react';
import { useRecoilState } from 'recoil';
import { settingsState, AppSettings } from '@/stores/settingsStore';
interface SettingsContextType {
settings: AppSettings;
updateSettings: (settings: Partial<AppSettings>) => void;
}
const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
export function SettingsContextProvider({ children }: { children: React.ReactNode }) {
const [settings, setSettings] = useRecoilState(settingsState);
const updateSettings = (newSettings: Partial<AppSettings>) => {
setSettings(prev => ({
...prev,
...newSettings,
}));
};
return (
<SettingsContext.Provider value={{ settings, updateSettings }}>
{children}
</SettingsContext.Provider>
);
}
export function useSettings() {
const context = useContext(SettingsContext);
if (context === undefined) {
throw new Error('useSettings must be used within a SettingsContextProvider');
}
return context;
}

47
src/CellularManagement.WebUI/src/contexts/ThemeContext.tsx

@ -0,0 +1,47 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
import { themeState } from '@/stores/themeStore';
interface ThemeContextType {
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeContextProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useRecoilState(themeState);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const setTheme = (theme: 'light' | 'dark') => {
setState({ theme });
};
const toggleTheme = () => {
setTheme(state.theme === 'light' ? 'dark' : 'light');
};
// 防止水合不匹配
if (!mounted) {
return null;
}
return (
<ThemeContext.Provider value={{ theme: state.theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeContextProvider');
}
return context;
}

34
src/CellularManagement.WebUI/src/contexts/ThemeProvider.tsx

@ -0,0 +1,34 @@
import { ReactNode, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { themeState } from '@/stores/themeStore';
interface ThemeProviderProps {
children: ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
const { theme } = useRecoilValue(themeState);
useEffect(() => {
// 应用主题
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(theme);
// 保存主题到本地存储
localStorage.setItem('theme', theme);
}, [theme]);
// 初始化主题
useEffect(() => {
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null;
if (savedTheme) {
document.documentElement.classList.add(savedTheme);
} else {
// 检查系统主题
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.add(prefersDark ? 'dark' : 'light');
}
}, []);
return <>{children}</>;
}

24
src/CellularManagement.WebUI/src/pages/roles/RoleForm.tsx

@ -36,11 +36,15 @@ export default function RoleForm({ onSubmit }: RoleFormProps) {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormLabel className="text-foreground"></FormLabel>
<FormControl>
<Input placeholder="请输入角色名" {...field} />
<Input
placeholder="请输入角色名"
{...field}
className="bg-background text-foreground placeholder:text-muted-foreground"
/>
</FormControl>
<FormMessage />
<FormMessage className="text-destructive" />
</FormItem>
)}
/>
@ -49,16 +53,22 @@ export default function RoleForm({ onSubmit }: RoleFormProps) {
name="description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormLabel className="text-foreground"></FormLabel>
<FormControl>
<Input placeholder="请输入描述" {...field} />
<Input
placeholder="请输入描述"
{...field}
className="bg-background text-foreground placeholder:text-muted-foreground"
/>
</FormControl>
<FormMessage />
<FormMessage className="text-destructive" />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit"></Button>
<Button type="submit" className="bg-primary text-primary-foreground hover:bg-primary/90">
</Button>
</div>
</form>
</Form>

132
src/CellularManagement.WebUI/src/pages/roles/RoleTable.tsx

@ -10,6 +10,14 @@ import {
} from '@/components/ui/table';
import { Role } from '@/services/roleService';
import { formatToBeijingTime } from '@/lib/utils';
import { DensityType } from '@/components/ui/TableToolbar';
interface ColumnConfig {
key: string;
title: string;
visible: boolean;
fixed?: boolean;
}
interface RoleTableProps {
roles: Role[];
@ -17,6 +25,13 @@ interface RoleTableProps {
onDelete: (roleId: string) => void;
onEdit?: (role: Role) => void;
onSetPermissions?: (role: Role) => void;
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
hideCard?: boolean;
density?: DensityType;
columns?: ColumnConfig[];
}
export default function RoleTable({
@ -25,73 +40,98 @@ export default function RoleTable({
onDelete,
onEdit,
onSetPermissions,
page,
pageSize,
total,
onPageChange,
hideCard = false,
density = 'default',
columns = [],
}: RoleTableProps) {
const totalPages = Math.ceil(total / pageSize);
const Wrapper = hideCard ? React.Fragment : 'div';
const wrapperProps = hideCard ? {} : { className: 'rounded-md border bg-background' };
const rowClass = density === 'relaxed' ? 'h-20' : density === 'compact' ? 'h-8' : 'h-12';
const cellPadding = density === 'relaxed' ? 'py-5' : density === 'compact' ? 'py-1' : 'py-3';
// 过滤可见列
const visibleColumns = columns.length > 0 ? columns.filter(col => col.visible) : [
{ key: 'name', title: '角色名' },
{ key: 'description', title: '描述' },
{ key: 'createdAt', title: '创建时间' },
{ key: 'updatedAt', title: '更新时间' },
{ key: 'actions', title: '操作' }
];
return (
<div className="rounded-md border bg-background">
<Wrapper {...wrapperProps}>
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-foreground"></TableHead>
<TableHead className="text-foreground"></TableHead>
<TableHead className="text-foreground"></TableHead>
<TableHead className="text-foreground"></TableHead>
<TableHead className="text-right text-foreground"></TableHead>
<TableRow className={rowClass}>
{visibleColumns.map(col => (
<TableHead
key={col.key}
className={`text-foreground ${col.key === 'actions' ? 'text-right' : ''} ${cellPadding}`}
>
{col.title}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
<TableRow className={rowClass}>
<TableCell colSpan={visibleColumns.length} className={`text-center text-muted-foreground ${cellPadding}`}>
...
</TableCell>
</TableRow>
) : roles.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
<TableRow className={rowClass}>
<TableCell colSpan={visibleColumns.length} className={`text-center text-muted-foreground ${cellPadding}`}>
</TableCell>
</TableRow>
) : (
roles.map((role) => (
<TableRow key={role.id}>
<TableCell className="text-foreground">{role.name}</TableCell>
<TableCell className="text-foreground">{role.description}</TableCell>
<TableCell className="text-foreground">{formatToBeijingTime(role.createdAt)}</TableCell>
<TableCell className="text-foreground">{formatToBeijingTime(role.updatedAt)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{onEdit && (
<Button
variant="outline"
size="sm"
onClick={() => onEdit(role)}
>
</Button>
)}
{onSetPermissions && (
<Button
variant="outline"
size="sm"
onClick={() => onSetPermissions(role)}
>
</Button>
)}
<Button
variant="destructive"
size="sm"
onClick={() => onDelete(role.id)}
>
</Button>
</div>
</TableCell>
<TableRow key={role.id} className={rowClass}>
{visibleColumns.map(col => {
if (col.key === 'name') return <TableCell key={col.key} className={`text-foreground ${cellPadding}`}>{role.name}</TableCell>;
if (col.key === 'description') return <TableCell key={col.key} className={`text-foreground ${cellPadding}`}>{role.description}</TableCell>;
if (col.key === 'createdAt') return <TableCell key={col.key} className={`text-foreground ${cellPadding}`}>{formatToBeijingTime(role.createdAt)}</TableCell>;
if (col.key === 'updatedAt') return <TableCell key={col.key} className={`text-foreground ${cellPadding}`}>{formatToBeijingTime(role.updatedAt)}</TableCell>;
if (col.key === 'actions') return (
<TableCell key={col.key} className={`text-right ${cellPadding}`}>
<div className="flex justify-end gap-4">
{onEdit && (
<span
className="cursor-pointer text-blue-600 hover:underline select-none"
onClick={() => onEdit(role)}
>
</span>
)}
{onSetPermissions && (
<span
className="cursor-pointer text-blue-600 hover:underline select-none"
onClick={() => onSetPermissions(role)}
>
</span>
)}
<span
className="cursor-pointer text-red-500 hover:underline select-none"
onClick={() => onDelete(role.id)}
>
</span>
</div>
</TableCell>
);
return null;
})}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</Wrapper>
);
}

114
src/CellularManagement.WebUI/src/pages/roles/RolesView.tsx

@ -5,7 +5,16 @@ import { roleService } from '@/services/roleService';
import RoleTable from './RoleTable';
import RoleForm from './RoleForm';
import { Role } from '@/services/roleService';
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from '@/components/ui/pagination';
import { Input } from '@/components/ui/input';
import PaginationBar from '@/components/ui/PaginationBar';
import TableToolbar, { DensityType } from '@/components/ui/TableToolbar';
const defaultColumns = [
{ key: 'name', title: '角色名称', visible: true },
{ key: 'description', title: '描述', visible: true },
{ key: 'createdAt', title: '添加时间', visible: true },
{ key: 'actions', title: '操作', visible: true }
];
export default function RolesView() {
const [roles, setRoles] = useState<Role[]>([]);
@ -15,12 +24,12 @@ export default function RolesView() {
const [roleName, setRoleName] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [density, setDensity] = useState<DensityType>('default');
const [columns, setColumns] = useState(defaultColumns);
const fetchRoles = async (params = {}) => {
setLoading(true);
console.log('请求参数:', { roleName, page, pageSize, ...params });
const result = await roleService.getAllRoles({ roleName, page, pageSize, ...params });
console.log('接口返回:', result);
if (result.success && result.data) {
setRoles(result.data.roles || []);
setTotal(result.data.totalCount || 0);
@ -30,7 +39,8 @@ export default function RolesView() {
useEffect(() => {
fetchRoles();
}, []);
// eslint-disable-next-line
}, [page, pageSize]);
const handleCreate = async (data: { name: string; description?: string }) => {
const result = await roleService.createRole(data);
@ -47,28 +57,90 @@ export default function RolesView() {
}
};
// 计算总页数
// 查询按钮
const handleQuery = () => {
setPage(1);
fetchRoles({ page: 1 });
};
// 重置按钮
const handleReset = () => {
setRoleName('');
setPage(1);
fetchRoles({ roleName: '', page: 1 });
};
// 每页条数选择
const handlePageSizeChange = (size: number) => {
setPageSize(size);
setPage(1);
};
const totalPages = Math.ceil(total / pageSize);
const rowClass = density === 'relaxed' ? 'h-20' : density === 'compact' ? 'h-8' : 'h-12';
const cellPadding = density === 'relaxed' ? 'py-5' : density === 'compact' ? 'py-1' : 'py-3';
return (
<main className="flex-1 p-4 transition-all duration-300 ease-in-out sm:p-6">
<div className="w-full space-y-6">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold"></h1>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button></Button>
</DialogTrigger>
<DialogContent>
<RoleForm onSubmit={handleCreate} />
</DialogContent>
</Dialog>
<div className="w-full space-y-4">
{/* 顶部搜索栏 */}
<div className="flex items-center bg-background p-4 rounded-md border mb-2 gap-4">
<Input
placeholder="请输入角色名称"
value={roleName}
onChange={e => setRoleName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleQuery(); }}
className="w-64 bg-background text-foreground placeholder:text-muted-foreground border border-border focus:outline-none focus:ring-0 focus:border-border transition-all mx-2"
/>
<div className="ml-auto flex gap-2">
<Button variant="outline" onClick={handleReset}></Button>
<Button onClick={handleQuery}></Button>
</div>
</div>
{/* 表格整体卡片区域,包括添加按钮、表格、分页 */}
<div className="rounded-md border bg-background p-4">
{/* 顶部操作栏:添加角色+工具栏 */}
<div className="flex items-center justify-between mb-2">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-primary text-primary-foreground hover:bg-primary/90">+ </Button>
</DialogTrigger>
<DialogContent className="bg-background">
<RoleForm onSubmit={handleCreate} />
</DialogContent>
</Dialog>
<TableToolbar
onRefresh={() => fetchRoles()}
onDensityChange={setDensity}
onColumnsChange={setColumns}
onColumnsReset={() => setColumns(defaultColumns)}
columns={columns}
density={density}
/>
</div>
{/* 表格区域 */}
<RoleTable
roles={roles}
loading={loading}
onDelete={handleDelete}
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
hideCard={true}
density={density}
columns={columns}
/>
{/* 分页 */}
<PaginationBar
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
</div>
<RoleTable
roles={roles}
loading={loading}
onDelete={handleDelete}
/>
</div>
</main>
);

19
src/CellularManagement.WebUI/src/providers/ThemeProvider.tsx

@ -1,19 +0,0 @@
import { ReactNode, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { appSettingsState } from '@/states/appState';
interface ThemeProviderProps {
children: ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
const settings = useRecoilValue(appSettingsState);
useEffect(() => {
// 应用主题
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(settings.theme);
}, [settings.theme]);
return <>{children}</>;
}

12
src/CellularManagement.WebUI/src/states/appState.ts → src/CellularManagement.WebUI/src/stores/appState.ts

@ -1,14 +1,18 @@
import { atom, selector } from 'recoil';
import { User } from '@/types/auth';
export interface AppSettings {
theme: 'light' | 'dark';
language: 'zh-CN' | 'en-US';
}
// 应用设置状态
export const appSettingsState = atom({
key: 'appSettingsState',
export const appSettingsState = atom<AppSettings>({
key: 'appSettings',
default: {
theme: 'light',
language: 'zh-CN',
sidebarCollapsed: false,
}
},
});
// 用户信息状态(与 AuthProvider 同步)

3
src/CellularManagement.WebUI/src/stores/index.ts

@ -0,0 +1,3 @@
export * from './themeStore';
export * from './settingsStore';
export * from './userStore';

18
src/CellularManagement.WebUI/src/stores/settingsStore.ts

@ -0,0 +1,18 @@
import { atom } from 'recoil';
export interface AppSettings {
language: 'zh-CN' | 'en-US';
notifications: boolean;
soundEnabled: boolean;
autoRefresh: boolean;
}
export const settingsState = atom<AppSettings>({
key: 'settingsState',
default: {
language: 'zh-CN',
notifications: true,
soundEnabled: true,
autoRefresh: true,
},
});

12
src/CellularManagement.WebUI/src/stores/themeStore.ts

@ -0,0 +1,12 @@
import { atom } from 'recoil';
export interface ThemeState {
theme: 'light' | 'dark';
}
export const themeState = atom<ThemeState>({
key: 'themeState',
default: {
theme: 'light',
},
});

15
src/CellularManagement.WebUI/src/stores/userStore.ts

@ -0,0 +1,15 @@
import { atom } from 'recoil';
export interface User {
id: string;
username: string;
email: string;
avatar?: string;
roles: string[];
permissions: string[];
}
export const userState = atom<User | null>({
key: 'userState',
default: null,
});
Loading…
Cancel
Save