- secure-storage-aes: AES-256-GCM 替代 XOR,保留 XOR 迁移读取 - crypto-polyfill: wx.getRandomValuesSync → crypto.getRandomValues - logger.ts: dev/prod 区分日志级别,生产不输出详情 - ErrorBoundary: 错误分类(network/render/unknown) + 结构化日志 - DataSyncScheduler: isSyncing 互斥防并发重复同步 - app.tsx 首行导入 crypto-polyfill
114 lines
3.0 KiB
TypeScript
114 lines
3.0 KiB
TypeScript
import { Component } from 'react';
|
|
import { View, Text } from '@tarojs/components';
|
|
import './index.scss';
|
|
|
|
interface Props {
|
|
children: React.ReactNode;
|
|
fallback?: React.ReactNode;
|
|
}
|
|
|
|
interface State {
|
|
hasError: boolean;
|
|
retryCount: number;
|
|
errorCategory: ErrorCategory;
|
|
}
|
|
|
|
type ErrorCategory = 'network' | 'render' | 'unknown';
|
|
|
|
const MAX_RETRIES = 3;
|
|
|
|
function classifyError(error: Error): ErrorCategory {
|
|
const msg = error.message?.toLowerCase() || '';
|
|
if (
|
|
msg.includes('network') ||
|
|
msg.includes('fetch') ||
|
|
msg.includes('timeout') ||
|
|
msg.includes('request:fail')
|
|
) {
|
|
return 'network';
|
|
}
|
|
if (
|
|
msg.includes('cannot read properties') ||
|
|
msg.includes('is not defined') ||
|
|
msg.includes('is not a function') ||
|
|
msg.includes('render')
|
|
) {
|
|
return 'render';
|
|
}
|
|
return 'unknown';
|
|
}
|
|
|
|
function logError(error: Error, info: React.ErrorInfo, category: ErrorCategory): void {
|
|
const isDev = process.env.NODE_ENV === 'development';
|
|
const entry = {
|
|
ts: new Date().toISOString(),
|
|
category,
|
|
message: error.message,
|
|
stack: isDev ? error.stack : undefined,
|
|
componentStack: isDev ? info.componentStack : undefined,
|
|
};
|
|
|
|
if (isDev) {
|
|
console.error('[ErrorBoundary]', JSON.stringify(entry, null, 2));
|
|
} else {
|
|
console.error('[ErrorBoundary]', entry.ts, entry.category, entry.message);
|
|
}
|
|
}
|
|
|
|
export default class ErrorBoundary extends Component<Props, State> {
|
|
constructor(props: Props) {
|
|
super(props);
|
|
this.state = { hasError: false, retryCount: 0, errorCategory: 'unknown' };
|
|
}
|
|
|
|
static getDerivedStateFromError(error: Error): Partial<State> {
|
|
return { hasError: true, errorCategory: classifyError(error) };
|
|
}
|
|
|
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
|
const category = classifyError(error);
|
|
logError(error, info, category);
|
|
this.setState((prev) => ({
|
|
retryCount: prev.retryCount + 1,
|
|
errorCategory: category,
|
|
}));
|
|
}
|
|
|
|
handleRetry = () => {
|
|
this.setState({ hasError: false });
|
|
};
|
|
|
|
render() {
|
|
if (this.state.hasError) {
|
|
if (this.props.fallback) {
|
|
return this.props.fallback;
|
|
}
|
|
|
|
const exceeded = this.state.retryCount >= MAX_RETRIES;
|
|
const isNetwork = this.state.errorCategory === 'network';
|
|
const title = isNetwork ? '网络连接失败' : '页面出了点问题';
|
|
const desc = exceeded
|
|
? '请重启小程序后重试'
|
|
: isNetwork
|
|
? '请检查网络后重试'
|
|
: '请返回重试';
|
|
|
|
return (
|
|
<View className='error-boundary'>
|
|
<View className='error-icon-wrap'>
|
|
<Text className='error-icon-text'>!</Text>
|
|
</View>
|
|
<Text className='error-title'>{title}</Text>
|
|
<Text className='error-desc'>{desc}</Text>
|
|
{!exceeded && (
|
|
<View className='error-retry-btn' onClick={this.handleRetry}>
|
|
<Text className='error-retry-text'>重新加载</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|