先推一版热重载问题修复 且带上了骨架屏效果的一版
This commit is contained in:
parent
14b175f28c
commit
8f8b34763f
@ -15,6 +15,7 @@ import proxy from './proxy';
|
||||
import routes from './routes';
|
||||
|
||||
const { REACT_APP_ENV } = process.env;
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
export default defineConfig({
|
||||
hash: true,
|
||||
@ -36,7 +37,7 @@ export default defineConfig({
|
||||
baseNavigator: true,
|
||||
},
|
||||
dynamicImport: {
|
||||
loading: '@/components/PageLoading/index',
|
||||
loading: '@/components/SmartLoading/index',
|
||||
},
|
||||
targets: {
|
||||
ie: 11,
|
||||
@ -45,13 +46,14 @@ export default defineConfig({
|
||||
routes,
|
||||
// Theme for antd: https://ant.design/docs/react/customize-theme-cn
|
||||
theme: {
|
||||
// 'primary-color': defaultSettings.primaryColor,
|
||||
'primary-color': defaultSettings.primaryColor,
|
||||
},
|
||||
title: false,
|
||||
ignoreMomentLocale: true,
|
||||
proxy: proxy[REACT_APP_ENV || 'dev'],
|
||||
base: '/cloud',
|
||||
publicPath: '/cloud/',
|
||||
publicPath: process.env.NODE_ENV === 'development' ? '/' : '/cloud/',
|
||||
// 快速刷新功能 https://umijs.org/config#fastrefresh
|
||||
fastRefresh: {},
|
||||
esbuild: {},
|
||||
@ -59,5 +61,5 @@ export default defineConfig({
|
||||
master: {}
|
||||
},
|
||||
webpack5: {
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
@ -63,7 +63,7 @@
|
||||
"@umijs/route-utils": "^1.0.33",
|
||||
"ahooks": "^2.10.3",
|
||||
"antd": "^4.17.0",
|
||||
"aws-sdk": "^2.1692.0",
|
||||
"aws-sdk": "2.1692.0",
|
||||
"classnames": "^2.2.6",
|
||||
"compression-webpack-plugin": "^11.1.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
|
||||
@ -6,8 +6,9 @@
|
||||
* @FilePath: \cloud-platform\src\app.ts
|
||||
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
||||
*/
|
||||
import './patchCssHmr';
|
||||
import './global.less';
|
||||
import globalState from './globalState';
|
||||
|
||||
// import { getMicroAppRouteComponent } from 'umi';
|
||||
|
||||
// 将全局state注入子应用
|
||||
|
||||
7
src/components/PageLoading/index.less
Normal file
7
src/components/PageLoading/index.less
Normal file
@ -0,0 +1,7 @@
|
||||
.page-loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
@ -1,5 +1,13 @@
|
||||
import { PageLoading } from '@ant-design/pro-layout';
|
||||
// import { PageLoading } from '@ant-design/pro-layout';
|
||||
|
||||
// // loading components from code split
|
||||
// // https://umijs.org/plugin/umi-plugin-react.html#dynamicimport
|
||||
// export default PageLoading;
|
||||
import React from 'react';
|
||||
import SkeletonLoading from '../SkeletonLoading';
|
||||
|
||||
const PageLoading: React.FC = () => {
|
||||
return <SkeletonLoading type="page" />;
|
||||
};
|
||||
|
||||
// loading components from code split
|
||||
// https://umijs.org/plugin/umi-plugin-react.html#dynamicimport
|
||||
export default PageLoading;
|
||||
65
src/components/PageTransition/index.less
Normal file
65
src/components/PageTransition/index.less
Normal file
@ -0,0 +1,65 @@
|
||||
// 简化版页面过渡动画
|
||||
.simple-page-transition {
|
||||
|
||||
// 默认不播放动画,只有带animate类时才播放
|
||||
&.animate {
|
||||
animation: pageEnter 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pageEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.96);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 复杂版页面过渡动画
|
||||
.page-transition {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
.transition-content {
|
||||
height: 100%;
|
||||
|
||||
&.fadeIn {
|
||||
animation: fadeIn 0.3s ease-in-out forwards;
|
||||
}
|
||||
|
||||
&.fadeOut {
|
||||
animation: fadeOut 0.3s ease-in-out forwards;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
72
src/components/PageTransition/index.tsx
Normal file
72
src/components/PageTransition/index.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group';
|
||||
import { useLocation } from 'umi';
|
||||
import './index.less';
|
||||
|
||||
export interface PageTransitionProps {
|
||||
children: React.ReactNode;
|
||||
type?: 'fade' | 'slide' | 'scale';
|
||||
duration?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PageTransition: React.FC<PageTransitionProps> = ({
|
||||
children,
|
||||
type = 'fade',
|
||||
duration = 300,
|
||||
className = '',
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const [displayLocation, setDisplayLocation] = useState(location);
|
||||
const [transitionStage, setTransitionStage] = useState('fadeIn');
|
||||
|
||||
useEffect(() => {
|
||||
if (location !== displayLocation) setTransitionStage('fadeOut');
|
||||
}, [location, displayLocation]);
|
||||
|
||||
return (
|
||||
<div className={`page-transition ${type}-transition ${className}`}>
|
||||
<div
|
||||
className={`transition-content ${transitionStage}`}
|
||||
onAnimationEnd={() => {
|
||||
if (transitionStage === 'fadeOut') {
|
||||
setDisplayLocation(location);
|
||||
setTransitionStage('fadeIn');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 简化版本 - 仅使用CSS动画
|
||||
export const SimplePageTransition: React.FC<{
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
enableAnimation?: boolean;
|
||||
onAnimationEnd?: () => void;
|
||||
}> = ({
|
||||
children,
|
||||
className = '',
|
||||
enableAnimation = true,
|
||||
onAnimationEnd,
|
||||
}) => {
|
||||
const handleAnimationEnd = () => {
|
||||
if (enableAnimation && onAnimationEnd) {
|
||||
onAnimationEnd();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`simple-page-transition ${enableAnimation ? 'animate' : ''} ${className}`}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageTransition;
|
||||
163
src/components/SkeletonLoading/index.less
Normal file
163
src/components/SkeletonLoading/index.less
Normal file
@ -0,0 +1,163 @@
|
||||
.skeleton-page-container {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
|
||||
.skeleton-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px 24px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.skeleton-header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
.skeleton-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px 24px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.skeleton-table-card {
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-table-container {
|
||||
padding: 16px;
|
||||
|
||||
.skeleton-table-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.skeleton-table-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f9f9f9;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-card-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
|
||||
.skeleton-card {
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-form-container {
|
||||
padding: 24px;
|
||||
|
||||
.skeleton-form-title {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.skeleton-form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.skeleton-form-label {
|
||||
width: 120px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.skeleton-form-control {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-form-actions {
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// 骨架屏动画优化
|
||||
.ant-skeleton-element {
|
||||
.ant-skeleton-input {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ant-skeleton-button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.skeleton-page-container {
|
||||
padding: 12px;
|
||||
|
||||
.skeleton-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
|
||||
.skeleton-header-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
.skeleton-filter-bar {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
|
||||
.ant-skeleton-input {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-card-container {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.skeleton-form-container {
|
||||
padding: 12px;
|
||||
|
||||
.skeleton-form-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.skeleton-form-label {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
110
src/components/SkeletonLoading/index.tsx
Normal file
110
src/components/SkeletonLoading/index.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { Skeleton, Card } from 'antd';
|
||||
import './index.less';
|
||||
|
||||
interface SkeletonLoadingProps {
|
||||
type?: 'page' | 'table' | 'card' | 'form';
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
const SkeletonLoading: React.FC<SkeletonLoadingProps> = ({
|
||||
type = 'page',
|
||||
rows = 3
|
||||
}) => {
|
||||
const renderPageSkeleton = () => (
|
||||
<div className="skeleton-page-container">
|
||||
<div className="skeleton-header">
|
||||
<Skeleton.Input style={{ width: 200, height: 32 }} active />
|
||||
<div className="skeleton-header-actions">
|
||||
<Skeleton.Button style={{ width: 80 }} active />
|
||||
<Skeleton.Button style={{ width: 80 }} active />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="skeleton-content">
|
||||
<div className="skeleton-filter-bar">
|
||||
<Skeleton.Input style={{ width: 150, marginRight: 16 }} active />
|
||||
<Skeleton.Input style={{ width: 150, marginRight: 16 }} active />
|
||||
<Skeleton.Button style={{ width: 60 }} active />
|
||||
</div>
|
||||
|
||||
<Card className="skeleton-table-card">
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderTableSkeleton = () => (
|
||||
<div className="skeleton-table-container">
|
||||
<div className="skeleton-table-header">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Skeleton.Input
|
||||
key={index}
|
||||
style={{ width: `${Math.random() * 50 + 80}px`, marginRight: 16 }}
|
||||
active
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<div key={index} className="skeleton-table-row">
|
||||
{Array.from({ length: 5 }).map((_, cellIndex) => (
|
||||
<Skeleton.Input
|
||||
key={cellIndex}
|
||||
style={{ width: `${Math.random() * 60 + 60}px`, marginRight: 16 }}
|
||||
active
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCardSkeleton = () => (
|
||||
<div className="skeleton-card-container">
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<Card key={index} className="skeleton-card">
|
||||
<Skeleton active avatar paragraph={{ rows: 2 }} />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderFormSkeleton = () => (
|
||||
<div className="skeleton-form-container">
|
||||
<Card>
|
||||
<div className="skeleton-form-title">
|
||||
<Skeleton.Input style={{ width: 150 }} active />
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<div key={index} className="skeleton-form-row">
|
||||
<div className="skeleton-form-label">
|
||||
<Skeleton.Input style={{ width: 80 }} active />
|
||||
</div>
|
||||
<div className="skeleton-form-control">
|
||||
<Skeleton.Input style={{ width: '100%' }} active />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="skeleton-form-actions">
|
||||
<Skeleton.Button style={{ width: 80, marginRight: 8 }} active />
|
||||
<Skeleton.Button style={{ width: 80 }} active />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case 'table':
|
||||
return renderTableSkeleton();
|
||||
case 'card':
|
||||
return renderCardSkeleton();
|
||||
case 'form':
|
||||
return renderFormSkeleton();
|
||||
case 'page':
|
||||
default:
|
||||
return renderPageSkeleton();
|
||||
}
|
||||
};
|
||||
|
||||
export default SkeletonLoading;
|
||||
107
src/components/SmartLoading/index.tsx
Normal file
107
src/components/SmartLoading/index.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'umi';
|
||||
import SkeletonLoading from '../SkeletonLoading';
|
||||
import { routePreloader } from '@/utils/routePreloader';
|
||||
|
||||
interface SmartLoadingProps {
|
||||
fallback?: React.ReactNode;
|
||||
enablePreload?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能加载组件
|
||||
* 根据路由类型自动选择合适的骨架屏,并支持路由预加载
|
||||
*/
|
||||
const SmartLoading: React.FC<SmartLoadingProps> = ({
|
||||
fallback,
|
||||
enablePreload = true
|
||||
}) => {
|
||||
const [loadingType, setLoadingType] = useState<'page' | 'table' | 'form' | 'card'>('page');
|
||||
const [shouldShow, setShouldShow] = useState(false);
|
||||
const [location, setLocation] = useState<any>(null);
|
||||
|
||||
// 安全获取location,避免在路由未准备好时出错
|
||||
try {
|
||||
const currentLocation = useLocation();
|
||||
if (!location) {
|
||||
setLocation(currentLocation);
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果useLocation失败,使用window.location作为fallback
|
||||
if (!location && typeof window !== 'undefined') {
|
||||
setLocation({ pathname: window.location.pathname });
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// 对于页面切换,立即显示骨架屏,但对于初始加载延迟显示
|
||||
const isInitialLoad = !location?.pathname || location.pathname === '/';
|
||||
|
||||
if (isInitialLoad) {
|
||||
// 初始加载时延迟显示,避免刷新时闪烁
|
||||
const timer = setTimeout(() => {
|
||||
setShouldShow(true);
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
// 页面切换时立即显示,保持骨架屏效果
|
||||
setShouldShow(true);
|
||||
}
|
||||
}, [location?.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!location?.pathname) return;
|
||||
|
||||
// 根据路径判断页面类型,选择合适的骨架屏
|
||||
const path = location.pathname;
|
||||
|
||||
if (path.includes('list') || path.includes('table')) {
|
||||
setLoadingType('table');
|
||||
} else if (path.includes('form') || path.includes('edit') || path.includes('add')) {
|
||||
setLoadingType('form');
|
||||
} else if (path.includes('card') || path.includes('dashboard')) {
|
||||
setLoadingType('card');
|
||||
} else {
|
||||
setLoadingType('page');
|
||||
}
|
||||
|
||||
// 预加载相关路由(降低优先级,避免影响主流程)
|
||||
if (enablePreload) {
|
||||
setTimeout(() => {
|
||||
routePreloader.preloadBasedOnUserBehavior(path);
|
||||
}, 500);
|
||||
}
|
||||
}, [location?.pathname, enablePreload]);
|
||||
|
||||
// 如果提供了自定义fallback,使用它
|
||||
if (fallback) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
// 延迟显示,避免闪烁
|
||||
if (!shouldShow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 根据页面类型返回对应的骨架屏
|
||||
return <SkeletonLoading type={loadingType} rows={getRowsByType(loadingType)} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据加载类型获取合适的行数
|
||||
*/
|
||||
function getRowsByType(type: string): number {
|
||||
switch (type) {
|
||||
case 'table':
|
||||
return 8; // 表格通常显示更多行
|
||||
case 'form':
|
||||
return 5; // 表单通常5-6个字段
|
||||
case 'card':
|
||||
return 6; // 卡片网格通常6个
|
||||
case 'page':
|
||||
default:
|
||||
return 4; // 默认4行
|
||||
}
|
||||
}
|
||||
|
||||
export default SmartLoading;
|
||||
79
src/components/TabVirtualizer/index.less
Normal file
79
src/components/TabVirtualizer/index.less
Normal file
@ -0,0 +1,79 @@
|
||||
.tab-virtualizer {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
&.unloaded {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
background: #fafafa;
|
||||
|
||||
.unloaded-placeholder {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
|
||||
.placeholder-icon {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
h3 {
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.placeholder-desc {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.last-access-time {
|
||||
color: #bbb;
|
||||
font-size: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.reload-btn {
|
||||
min-width: 120px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.2);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(24, 144, 255, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.loaded {
|
||||
// 正常加载状态的样式
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
96
src/components/TabVirtualizer/index.tsx
Normal file
96
src/components/TabVirtualizer/index.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { ReloadOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||
import PageLoading from '../PageLoading';
|
||||
import './index.less';
|
||||
|
||||
export interface TabVirtualizerProps {
|
||||
children: React.ReactNode;
|
||||
tabKey: string;
|
||||
isActive: boolean;
|
||||
isLoaded: boolean;
|
||||
isLoading: boolean;
|
||||
lastAccessTime?: number;
|
||||
onReload: (tabKey: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TabVirtualizer: React.FC<TabVirtualizerProps> = ({
|
||||
children,
|
||||
tabKey,
|
||||
isActive,
|
||||
isLoaded,
|
||||
isLoading,
|
||||
lastAccessTime,
|
||||
onReload,
|
||||
className = '',
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 格式化最后访问时间
|
||||
const formatLastAccessTime = (timestamp?: number) => {
|
||||
if (!timestamp) return '';
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}小时前访问`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}分钟前访问`;
|
||||
} else {
|
||||
return '刚刚访问';
|
||||
}
|
||||
};
|
||||
|
||||
// 如果正在加载,显示加载状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`tab-virtualizer loading ${className}`} ref={containerRef}>
|
||||
<PageLoading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果未加载(被卸载),显示占位符
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<div className={`tab-virtualizer unloaded ${className}`} ref={containerRef}>
|
||||
<div className="unloaded-placeholder">
|
||||
<div className="placeholder-icon">
|
||||
<ClockCircleOutlined style={{ fontSize: 48, color: '#d9d9d9' }} />
|
||||
</div>
|
||||
<div className="placeholder-content">
|
||||
<h3>页面已休眠</h3>
|
||||
<p className="placeholder-desc">
|
||||
为了优化内存使用,此页面内容已被暂时卸载
|
||||
</p>
|
||||
{lastAccessTime && (
|
||||
<p className="last-access-time">
|
||||
{formatLastAccessTime(lastAccessTime)}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => onReload(tabKey)}
|
||||
className="reload-btn"
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 正常渲染内容
|
||||
return (
|
||||
<div className={`tab-virtualizer loaded ${className}`} ref={containerRef}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabVirtualizer;
|
||||
@ -24,6 +24,51 @@ body {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
// 骨架屏优化样式
|
||||
.ant-skeleton {
|
||||
.ant-skeleton-content {
|
||||
|
||||
.ant-skeleton-title,
|
||||
.ant-skeleton-paragraph>li {
|
||||
background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 50%, #f2f2f2 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-skeleton-avatar {
|
||||
background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 50%, #f2f2f2 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ant-skeleton-input,
|
||||
.ant-skeleton-button {
|
||||
background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 50%, #f2f2f2 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 页面切换动画优化
|
||||
.ant-layout-content {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
&.loading {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
list-style: none;
|
||||
@ -56,3 +101,17 @@ ol {
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
// 页面过渡优化
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// 增强页面切换体验
|
||||
.ant-tabs-content-holder {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
outline: none;
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
@import '~antd/es/style/themes/default.less';
|
||||
@import '../components/PageTransition/index.less';
|
||||
|
||||
.ant-pro-global-header {
|
||||
box-shadow: none !important;
|
||||
|
||||
@ -15,68 +15,101 @@ type SecurityLayoutProps = {
|
||||
|
||||
type SecurityLayoutState = {
|
||||
isReady: boolean;
|
||||
shouldShowLoading: boolean;
|
||||
initialLoadComplete: boolean;
|
||||
};
|
||||
|
||||
class SecurityLayout extends React.Component<SecurityLayoutProps, SecurityLayoutState> {
|
||||
state: SecurityLayoutState = {
|
||||
isReady: false,
|
||||
|
||||
shouldShowLoading: false,
|
||||
initialLoadComplete: false,
|
||||
};
|
||||
|
||||
private loadingTimer?: NodeJS.Timeout;
|
||||
|
||||
componentDidMount() {
|
||||
|
||||
const { location } = history
|
||||
|
||||
const { location } = history;
|
||||
const { dispatch } = this.props;
|
||||
if (dispatch) {
|
||||
|
||||
// 检查是否有缓存的用户信息,避免不必要的加载状态
|
||||
const cachedUser = session.get("currentUser");
|
||||
|
||||
// 设置防闪烁定时器,只有加载时间超过300ms才显示loading
|
||||
this.loadingTimer = setTimeout(() => {
|
||||
if (!this.state.isReady && !this.state.initialLoadComplete) {
|
||||
this.setState({
|
||||
shouldShowLoading: true,
|
||||
});
|
||||
}
|
||||
}, 300);
|
||||
|
||||
if (dispatch) {
|
||||
dispatch({
|
||||
type: 'user/fetch', callback: (user) => {
|
||||
type: 'user/fetch',
|
||||
callback: (user) => {
|
||||
// 清除定时器
|
||||
if (this.loadingTimer) {
|
||||
clearTimeout(this.loadingTimer);
|
||||
}
|
||||
|
||||
if (user.code && location.pathname !== '/user/login') {
|
||||
history.push('/user/login');
|
||||
return
|
||||
return;
|
||||
}
|
||||
console.log('secur')
|
||||
dispatch({
|
||||
type: 'global/getMenuData', payload: user.ID, callback: (menu) => {
|
||||
if (menu) {
|
||||
|
||||
dispatch({
|
||||
type: 'global/getMenuData',
|
||||
payload: user.ID,
|
||||
callback: (menu) => {
|
||||
if (menu) {
|
||||
this.setState({
|
||||
isReady: true,
|
||||
shouldShowLoading: false,
|
||||
initialLoadComplete: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
});
|
||||
} else {
|
||||
// 清除定时器
|
||||
if (this.loadingTimer) {
|
||||
clearTimeout(this.loadingTimer);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isReady: true,
|
||||
shouldShowLoading: false,
|
||||
initialLoadComplete: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.loadingTimer) {
|
||||
clearTimeout(this.loadingTimer);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isReady } = this.state;
|
||||
const { children, loading, currentUser } = this.props;
|
||||
// const { location } = history;
|
||||
const { isReady, shouldShowLoading, initialLoadComplete } = this.state;
|
||||
const { children, currentUser } = this.props;
|
||||
|
||||
|
||||
// You can replace it to your authentication rule (such as check token exists)
|
||||
// You can replace it with your own login authentication rules (such as judging whether the token exists)
|
||||
// 用户认证规则
|
||||
const isLogin = currentUser && currentUser.ID;
|
||||
|
||||
|
||||
if ((!isLogin && loading) || !isReady) {
|
||||
// 如果初始化未完成且需要显示加载状态,才显示骨架屏
|
||||
if (!isReady && shouldShowLoading) {
|
||||
return <PageLoading />;
|
||||
}
|
||||
// if (!isLogin && location.pathname !== '/user/login') {
|
||||
// history.push('/user/login');
|
||||
// }
|
||||
|
||||
// 如果还在初始化过程中,但不需要显示loading,返回null(避免闪烁)
|
||||
if (!isReady && !shouldShowLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,14 +12,16 @@
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
line-height: 64px;
|
||||
display:flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #fff;
|
||||
box-shadow: 0px 3px 9px 0px rgba(204,212,230,0.84);
|
||||
box-shadow: 0px 3px 9px 0px rgba(204, 212, 230, 0.84);
|
||||
|
||||
:global(.ant-dropdown-trigger) {
|
||||
margin-right: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.headtitle {
|
||||
color: #191919;
|
||||
font-weight: 700;
|
||||
|
||||
@ -56,18 +56,18 @@ const UserLayout: React.FC<UserLayoutProps> = (props) => {
|
||||
<div className={styles.lang}>
|
||||
<Link to="/">
|
||||
<img alt="logo" className={styles.logo} src={logo} />
|
||||
<span className={styles.headtitle}>驿商云</span>
|
||||
<span className={styles.headtitle}>云南交投</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
<DefaultFooter
|
||||
{/* <DefaultFooter
|
||||
copyright={`Copyright © 2013-${new Date().getFullYear()} Eshang Cloud. All Rights Reserved. 驿商云 版权所有 V${VERSION || ''}`}
|
||||
// copyright={`Copyright © 2013-${new Date().getFullYear()} Eshang Cloud. All Rights Reserved. 驿商云 版权所有 V6.6.6`}
|
||||
links={[]}
|
||||
/>
|
||||
/> */}
|
||||
</div>
|
||||
</HelmetProvider>
|
||||
);
|
||||
|
||||
95
src/patchCssHmr.ts
Normal file
95
src/patchCssHmr.ts
Normal file
@ -0,0 +1,95 @@
|
||||
// src/hmrCssFix.ts
|
||||
// 仅开发环境执行
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const getBase = () => {
|
||||
const base = (window as any).routerBase || '/';
|
||||
return (base as string).endsWith('/') ? base.slice(0, -1) : base;
|
||||
};
|
||||
|
||||
const UMI_CSS_RE = /\/umi\.css(\?|#|$)/;
|
||||
|
||||
const createStylesheetLink = (href: string) => {
|
||||
const l = document.createElement('link');
|
||||
l.rel = 'stylesheet';
|
||||
// 加个 cache-bust,避免 HMR/缓存干扰
|
||||
l.href = href + (href.indexOf('?') > -1 ? '&' : '?') + '_hmrfix_=' + Date.now();
|
||||
l.setAttribute('data-hmrfix', 'stylesheet');
|
||||
return l;
|
||||
};
|
||||
|
||||
const injectImportStyle = (href: string) => {
|
||||
if (document.querySelector('style[data-hmrfix="import"]')) return;
|
||||
const s = document.createElement('style');
|
||||
s.setAttribute('data-hmrfix', 'import');
|
||||
s.appendChild(document.createTextNode(`@import url("${href}");`));
|
||||
document.head.appendChild(s);
|
||||
};
|
||||
|
||||
const hardFix = () => {
|
||||
const base = getBase();
|
||||
const href = `${base}/umi.css`;
|
||||
|
||||
// 1) 先把 preload as=style 全部替换成真正的 stylesheet(克隆+替换,避免被后续还原)
|
||||
const preloads = Array.from(
|
||||
document.querySelectorAll<HTMLLinkElement>('link[rel="preload"][as="style"]'),
|
||||
);
|
||||
preloads.forEach((pl) => {
|
||||
const isUmi = UMI_CSS_RE.test(pl.href);
|
||||
// 无论是不是 umi.css,都改;Umi 的其他 chunk css 被 preload 也会导致样式不生效
|
||||
const newLink = createStylesheetLink(pl.href);
|
||||
pl.replaceWith(newLink);
|
||||
});
|
||||
|
||||
// 2) 如果 umi.css 对应的 stylesheet 还不存在,就强插一个
|
||||
const hasUmiSheet = Array.from(
|
||||
document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'),
|
||||
).some((l) => UMI_CSS_RE.test(l.href));
|
||||
|
||||
if (!hasUmiSheet) {
|
||||
document.head.appendChild(createStylesheetLink(href));
|
||||
}
|
||||
|
||||
// 3) 兜底再兜底:若 50ms 后仍然没有 stylesheet,就用 @import 方式强行生效
|
||||
setTimeout(() => {
|
||||
const stillNoSheet = !Array.from(
|
||||
document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'),
|
||||
).some((l) => UMI_CSS_RE.test(l.href));
|
||||
if (stillNoSheet) {
|
||||
injectImportStyle(href);
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const run = () => {
|
||||
try {
|
||||
hardFix();
|
||||
} catch { }
|
||||
};
|
||||
|
||||
// 初次与 DOM 就绪
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
|
||||
// 监听 <head> 变化(HMR 注入/替换节点时立即修正)
|
||||
const mo = new MutationObserver(run);
|
||||
mo.observe(document.head, { childList: true, subtree: true });
|
||||
|
||||
// 监听 HMR 状态(apply/idle 后多次修正,压住竞态)
|
||||
// @ts-ignore
|
||||
if (typeof module !== 'undefined' && module?.hot?.addStatusHandler) {
|
||||
// @ts-ignore
|
||||
module.hot.addStatusHandler((status: string) => {
|
||||
if (status === 'apply' || status === 'idle') {
|
||||
setTimeout(run, 0);
|
||||
setTimeout(run, 50);
|
||||
setTimeout(run, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 兜底定时器(极端情况下也能拉回)
|
||||
setInterval(run, 300);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user