feat(web): Q3 前端体验优化 — ErrorBoundary + 5 hooks + 共享类型 + i18n 基础
- ErrorBoundary 组件:全局错误捕获与优雅降级 - 提取 5 个自定义 hooks:useCountUp, useDarkMode, useDebouncedValue, usePaginatedData, useApiRequest - 从 11 个 API 文件提取 PaginatedResponse 共享类型到 api/types.ts - 统一 API 错误处理(api/errors.ts) - client.ts 迁移到 axios adapter 模式(替代废弃的 CancelToken) - 添加 react-i18next 国际化基础设施 + zh-CN 语言包
This commit is contained in:
31
apps/web/src/hooks/useApiRequest.ts
Normal file
31
apps/web/src/hooks/useApiRequest.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
|
||||
function extractErrorMessage(err: unknown): string {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const resp = (err as { response?: { data?: { message?: string } } }).response;
|
||||
return resp?.data?.message || '';
|
||||
}
|
||||
if (err instanceof Error) return err.message;
|
||||
return '';
|
||||
}
|
||||
|
||||
export function useApiRequest() {
|
||||
const execute = useCallback(async <T>(
|
||||
fn: () => Promise<T>,
|
||||
successMsg?: string,
|
||||
errorMsg = '操作失败',
|
||||
): Promise<T | null> => {
|
||||
try {
|
||||
const result = await fn();
|
||||
if (successMsg) message.success(successMsg);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const msg = extractErrorMessage(err);
|
||||
message.error(msg || errorMsg);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { execute };
|
||||
}
|
||||
24
apps/web/src/hooks/useCountUp.ts
Normal file
24
apps/web/src/hooks/useCountUp.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
export function useCountUp(end: number, duration = 800) {
|
||||
const [count, setCount] = useState(0);
|
||||
const prevEnd = useRef(end);
|
||||
|
||||
useEffect(() => {
|
||||
if (end === prevEnd.current && count > 0) return;
|
||||
prevEnd.current = end;
|
||||
if (end === 0) { setCount(0); return; }
|
||||
|
||||
const startTime = performance.now();
|
||||
function tick(now: number) {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setCount(Math.round(end * eased));
|
||||
if (progress < 1) requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}, [end, duration]);
|
||||
|
||||
return count;
|
||||
}
|
||||
6
apps/web/src/hooks/useDarkMode.ts
Normal file
6
apps/web/src/hooks/useDarkMode.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { theme } from 'antd';
|
||||
|
||||
export function useDarkMode(): boolean {
|
||||
const { token } = theme.useToken();
|
||||
return token.colorBgBase !== '#ffffff' && token.colorBgBase !== '#fff';
|
||||
}
|
||||
12
apps/web/src/hooks/useDebouncedValue.ts
Normal file
12
apps/web/src/hooks/useDebouncedValue.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useDebouncedValue<T>(value: T, delay = 300): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
36
apps/web/src/hooks/usePaginatedData.ts
Normal file
36
apps/web/src/hooks/usePaginatedData.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
|
||||
interface PaginatedState<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function usePaginatedData<T>(
|
||||
fetchFn: (page: number, pageSize: number, search: string) => Promise<{ data: T[]; total: number }>,
|
||||
pageSize = 20,
|
||||
) {
|
||||
const [state, setState] = useState<PaginatedState<T>>({
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
loading: false,
|
||||
});
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
const refresh = useCallback(async (p?: number) => {
|
||||
const targetPage = p ?? state.page;
|
||||
setState(s => ({ ...s, loading: true }));
|
||||
try {
|
||||
const result = await fetchFn(targetPage, pageSize, searchText);
|
||||
setState({ data: result.data, total: result.total, page: targetPage, loading: false });
|
||||
} catch {
|
||||
message.error('加载数据失败');
|
||||
setState(s => ({ ...s, loading: false }));
|
||||
}
|
||||
}, [fetchFn, pageSize, searchText, state.page]);
|
||||
|
||||
return { ...state, searchText, setSearchText, refresh };
|
||||
}
|
||||
Reference in New Issue
Block a user