refactor: 前端应用骨架迁移至 zustand 并统一路由权限壳

This commit is contained in:
2026-08-05 17:10:30 +08:00
parent e21f0de427
commit d53bbd8176
33 changed files with 1055 additions and 687 deletions

View File

@@ -0,0 +1,24 @@
import { ReadOutlined } from '@ant-design/icons';
const BRAND_COLOR = '#7e14ff';
/** 全局品牌标识:登录页 / 侧边栏 / 页头统一使用 */
export function BrandLogo({ size = 32 }: { size?: number }) {
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: size,
height: size,
borderRadius: 8,
background: BRAND_COLOR,
color: '#fff',
flexShrink: 0,
}}
>
<ReadOutlined style={{ fontSize: size * 0.55 }} />
</span>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { Navigate } from 'react-router';
import { Result, Spin } from 'antd';
import { usePermission } from '../hooks/usePermission';
import { findRoleAwareLandingPath } from '../auth/menu-policy';
@@ -7,10 +7,10 @@ import { useUserStore } from '../store/user/userStore';
const DefaultRoute: React.FC = () => {
const { permissions, permissionsReady } = usePermission();
const roles = useUserStore((state) => state.user?.roles ?? []);
if (!permissionsReady) {
return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
}
const roles = useUserStore((state) => state.user?.roles ?? []);
const firstPath = findRoleAwareLandingPath(roles, permissions);
if (firstPath) return <Navigate to={firstPath} replace />;
return (

View File

@@ -1,7 +1,9 @@
import React, { useState, useEffect, useRef } from 'react';
import { Badge, Popover, Button, List, Typography, Empty } from 'antd';
import { BellOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import dayjs from 'dayjs';
import { useInterval } from 'usehooks-ts';
import api from '../api';
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
import { useUserStore } from '../store/user/userStore';
@@ -17,25 +19,19 @@ interface NotificationItem {
}
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return '刚刚';
if (mins < 60) return `${mins}分钟前`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}小时前`;
const days = Math.floor(hours / 24);
return `${days}天前`;
return dayjs(dateStr).fromNow();
}
const NotificationBell: React.FC = () => {
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [open, setOpen] = useState(false);
const [sseDown, setSseDown] = useState(false);
const navigate = useNavigate();
const fetchNotifications = async () => {
try {
const data = (await api.get('/notifications?limit=20')) as unknown as NotificationItem[];
const data = await api.get<NotificationItem[]>('/notifications?limit=20');
setNotifications(data);
} catch {
/* ignore */
@@ -44,7 +40,7 @@ const NotificationBell: React.FC = () => {
const fetchUnread = async () => {
try {
const data = (await api.get('/notifications/unread-count')) as unknown as { count: number };
const data = await api.get<{ count: number }>('/notifications/unread-count');
setUnreadCount(data.count);
} catch {
/* ignore */
@@ -52,7 +48,9 @@ const NotificationBell: React.FC = () => {
};
const openRef = useRef(open);
openRef.current = open;
const retryRef = useRef<number | null>(null);
useInterval(() => {
void fetchUnread();
}, sseDown ? 60_000 : null);
// SSE connection — decoupled from popover open state
useEffect(() => {
@@ -71,13 +69,11 @@ const NotificationBell: React.FC = () => {
};
es.onerror = () => {
es.close();
if (retryRef.current !== null) clearInterval(retryRef.current);
retryRef.current = window.setInterval(fetchUnread, 60_000);
setSseDown(true);
};
return () => {
es.close();
clearInterval(retryRef.current ?? undefined);
retryRef.current = null;
setSseDown(false);
};
}, []);

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { Button } from 'antd';
import type { ButtonProps } from 'antd';
import { Button, type ButtonProps } from 'antd';
import { usePermission } from '../hooks/usePermission';
interface PermissionButtonProps extends ButtonProps {

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Result, Button, Spin } from 'antd';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { findRoleAwareLandingPath } from '../auth/menu-policy';
import { usePermission } from '../hooks/usePermission';
import { useUserStore } from '../store/user/userStore';

View File

@@ -1,6 +1,12 @@
import React, { useEffect, useMemo } from 'react';
import type { DragEndEvent } from '@dnd-kit/core';
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
horizontalListSortingStrategy,
@@ -8,9 +14,8 @@ import {
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Tabs } from 'antd';
import type { TabsProps } from 'antd';
import type { Location } from 'react-router-dom';
import { Tabs, type TabsProps } from 'antd';
import type { Location } from 'react-router';
import type { AppMenuItem } from '../../auth/menu-policy';
import { useAppStore } from '../../store';

View File

@@ -1,6 +1,6 @@
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router';
import { afterEach, describe, expect, it } from 'vitest';
import { RouteKeeper } from './RouteKeeper';

View File

@@ -1,5 +1,5 @@
import React, { useRef } from 'react';
import { useLocation, useOutlet } from 'react-router-dom';
import { useLocation, useOutlet } from 'react-router';
const MAX_CACHED_PAGES = 30;