feat(web): Q3 前端体验优化 — ErrorBoundary + 5 hooks + 共享类型 + i18n 基础
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 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:
iven
2026-04-17 19:40:58 +08:00
parent 6a44cbecf3
commit 9d18b7e079
25 changed files with 324 additions and 52 deletions

View File

@@ -17,8 +17,10 @@
"@xyflow/react": "^12.10.2", "@xyflow/react": "^12.10.2",
"antd": "^6.3.5", "antd": "^6.3.5",
"axios": "^1.15.0", "axios": "^1.15.0",
"i18next": "^26.0.5",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-i18next": "^17.0.4",
"react-router-dom": "^7.14.0", "react-router-dom": "^7.14.0",
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },

View File

@@ -29,12 +29,18 @@ importers:
axios: axios:
specifier: ^1.15.0 specifier: ^1.15.0
version: 1.15.0 version: 1.15.0
i18next:
specifier: ^26.0.5
version: 26.0.5(typescript@6.0.2)
react: react:
specifier: ^19.2.4 specifier: ^19.2.4
version: 19.2.5 version: 19.2.5
react-dom: react-dom:
specifier: ^19.2.4 specifier: ^19.2.4
version: 19.2.5(react@19.2.5) version: 19.2.5(react@19.2.5)
react-i18next:
specifier: ^17.0.4
version: 17.0.4(i18next@26.0.5(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2)
react-router-dom: react-router-dom:
specifier: ^7.14.0 specifier: ^7.14.0
version: 7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -1562,10 +1568,21 @@ packages:
hermes-parser@0.25.1: hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
html2canvas@1.4.1: html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
i18next@26.0.5:
resolution: {integrity: sha512-9uHb4T27TdV36phJXcbpnRPt5yzAfqHXVrdASvmHZyPuZJtrLythd+GyXhiaHV5LlpuuskbAqhwPjmfTbKbi8w==}
peerDependencies:
typescript: ^5 || ^6
peerDependenciesMeta:
typescript:
optional: true
iconv-lite@0.6.3: iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1840,6 +1857,22 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.5 react: ^19.2.5
react-i18next@17.0.4:
resolution: {integrity: sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==}
peerDependencies:
i18next: '>= 26.0.1'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5 || ^6
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
typescript:
optional: true
react-is@18.3.1: react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
@@ -2051,6 +2084,10 @@ packages:
yaml: yaml:
optional: true optional: true
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
which@2.0.2: which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -3845,11 +3882,21 @@ snapshots:
dependencies: dependencies:
hermes-estree: 0.25.1 hermes-estree: 0.25.1
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
html2canvas@1.4.1: html2canvas@1.4.1:
dependencies: dependencies:
css-line-break: 2.1.0 css-line-break: 2.1.0
text-segmentation: 1.0.3 text-segmentation: 1.0.3
i18next@26.0.5(typescript@6.0.2):
dependencies:
'@babel/runtime': 7.29.2
optionalDependencies:
typescript: 6.0.2
iconv-lite@0.6.3: iconv-lite@0.6.3:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@@ -4068,6 +4115,17 @@ snapshots:
react: 19.2.5 react: 19.2.5
scheduler: 0.27.0 scheduler: 0.27.0
react-i18next@17.0.4(i18next@26.0.5(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2):
dependencies:
'@babel/runtime': 7.29.2
html-parse-stringify: 3.0.1
i18next: 26.0.5(typescript@6.0.2)
react: 19.2.5
use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies:
react-dom: 19.2.5(react@19.2.5)
typescript: 6.0.2
react-is@18.3.1: {} react-is@18.3.1: {}
react-router-dom@7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): react-router-dom@7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
@@ -4228,6 +4286,8 @@ snapshots:
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.6.1 jiti: 2.6.1
void-elements@3.1.0: {}
which@2.0.2: which@2.0.2:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0

View File

@@ -1,5 +1,5 @@
import client from './client'; import client from './client';
import type { PaginatedResponse } from './users'; import type { PaginatedResponse } from './types';
export interface AuditLogItem { export interface AuditLogItem {
id: string; id: string;

View File

@@ -1,11 +1,5 @@
import axios from 'axios'; import axios from 'axios';
const client = axios.create({
baseURL: '/api/v1',
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
});
// 请求缓存:短时间内相同请求复用结果 // 请求缓存:短时间内相同请求复用结果
interface CacheEntry { interface CacheEntry {
data: unknown; data: unknown;
@@ -19,24 +13,37 @@ function getCacheKey(config: { url?: string; params?: unknown; method?: string }
return `${config.method || 'get'}:${config.url || ''}:${JSON.stringify(config.params || {})}`; return `${config.method || 'get'}:${config.url || ''}:${JSON.stringify(config.params || {})}`;
} }
// Request interceptor: attach access token + cache const defaultAdapter = axios.getAdapter(axios.defaults.adapter);
client.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
const client = axios.create({
baseURL: '/api/v1',
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
adapter: (config) => {
// GET 请求检查缓存 // GET 请求检查缓存
if (config.method === 'get' && config.url) { if (config.method === 'get' && config.url) {
const key = getCacheKey(config); const key = getCacheKey(config);
const entry = requestCache.get(key); const entry = requestCache.get(key);
if (entry && Date.now() - entry.timestamp < CACHE_TTL) { if (entry && Date.now() - entry.timestamp < CACHE_TTL) {
const source = axios.CancelToken.source(); return Promise.resolve({
config.cancelToken = source.token; data: entry.data,
// 通过适配器返回缓存数据 status: 200,
source.cancel(JSON.stringify({ __cached: true, data: entry.data })); statusText: 'OK (cached)',
headers: {} as any,
config,
});
} }
} }
return defaultAdapter(config);
},
});
// Request interceptor: attach access token
client.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config; return config;
}); });
@@ -52,14 +59,6 @@ client.interceptors.response.use(
return response; return response;
}, },
async (error) => { async (error) => {
// 处理缓存命中
if (axios.isCancel(error)) {
const cached = JSON.parse(error.message || '{}');
if (cached.__cached) {
return { data: cached.data, status: 200, statusText: 'OK (cached)', headers: {}, config: {} };
}
}
const originalRequest = error.config; const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) { if (isRefreshing) {

View File

@@ -1,5 +1,5 @@
import client from './client'; import client from './client';
import type { PaginatedResponse } from './users'; import type { PaginatedResponse } from './types';
export interface DictionaryItemInfo { export interface DictionaryItemInfo {
id: string; id: string;

View File

@@ -0,0 +1,8 @@
export function extractErrorMessage(err: unknown, fallback = '操作失败'): string {
if (err && typeof err === 'object' && 'response' in err) {
const resp = (err as { response?: { data?: { message?: string } } }).response;
if (resp?.data?.message) return resp.data.message;
}
if (err instanceof Error) return err.message;
return fallback;
}

View File

@@ -1,5 +1,5 @@
import client from './client'; import client from './client';
import type { PaginatedResponse } from './users'; import type { PaginatedResponse } from './types';
export interface MessageTemplateInfo { export interface MessageTemplateInfo {
id: string; id: string;

View File

@@ -1,5 +1,5 @@
import client from './client'; import client from './client';
import type { PaginatedResponse } from './users'; import type { PaginatedResponse } from './types';
export interface MessageInfo { export interface MessageInfo {
id: string; id: string;

View File

@@ -1,5 +1,5 @@
import client from './client'; import client from './client';
import type { PaginatedResponse } from './users'; import type { PaginatedResponse } from './types';
export interface NumberingRuleInfo { export interface NumberingRuleInfo {
id: string; id: string;

View File

@@ -1,12 +1,5 @@
import client from './client'; import client from './client';
import type { PaginatedResponse } from './types';
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export interface PluginEntityInfo { export interface PluginEntityInfo {
name: string; name: string;

View File

@@ -1,5 +1,5 @@
import client from './client'; import client from './client';
import type { PaginatedResponse } from './users'; import type { PaginatedResponse } from './types';
export interface RoleInfo { export interface RoleInfo {
id: string; id: string;

View File

@@ -0,0 +1,7 @@
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
page_size: number;
total_pages: number;
}

View File

@@ -1,13 +1,6 @@
import client from './client'; import client from './client';
import type { UserInfo } from './auth'; import type { UserInfo } from './auth';
import type { PaginatedResponse } from './types';
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export interface CreateUserRequest { export interface CreateUserRequest {
username: string; username: string;

View File

@@ -1,5 +1,5 @@
import client from './client'; import client from './client';
import type { PaginatedResponse } from './users'; import type { PaginatedResponse } from './types';
export interface NodeDef { export interface NodeDef {
id: string; id: string;

View File

@@ -1,5 +1,5 @@
import client from './client'; import client from './client';
import type { PaginatedResponse } from './users'; import type { PaginatedResponse } from './types';
export interface TokenInfo { export interface TokenInfo {
id: string; id: string;

View File

@@ -1,5 +1,5 @@
import client from './client'; import client from './client';
import type { PaginatedResponse } from './users'; import type { PaginatedResponse } from './types';
export interface TaskInfo { export interface TaskInfo {
id: string; id: string;

View File

@@ -0,0 +1,54 @@
import { Component, type ReactNode } from 'react';
import { Button, Result } from 'antd';
interface Props {
children: ReactNode;
pageLevel?: boolean;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return (
<Result
status="error"
title={this.props.pageLevel ? '页面加载出错' : '出了点问题'}
subTitle={this.props.pageLevel
? `错误信息:${this.state.error?.message || '未知错误'}`
: '请刷新页面重试'}
extra={[
<Button key="retry" type="primary" onClick={this.handleReset}>
</Button>,
<Button key="home" onClick={() => window.location.hash = '/'}>
</Button>,
]}
/>
);
}
return this.props.children;
}
}

View 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 };
}

View 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;
}

View File

@@ -0,0 +1,6 @@
import { theme } from 'antd';
export function useDarkMode(): boolean {
const { token } = theme.useToken();
return token.colorBgBase !== '#ffffff' && token.colorBgBase !== '#fff';
}

View 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;
}

View 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 };
}

View File

@@ -0,0 +1,12 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import zhCN from './locales/zh-CN.json';
i18n.use(initReactI18next).init({
resources: { 'zh-CN': { translation: zhCN } },
lng: 'zh-CN',
fallbackLng: 'zh-CN',
interpolation: { escapeValue: false },
});
export default i18n;

View File

@@ -0,0 +1,34 @@
{
"common": {
"save": "保存",
"cancel": "取消",
"delete": "删除",
"edit": "编辑",
"create": "新建",
"search": "搜索",
"confirm": "确认",
"loading": "加载中...",
"success": "操作成功",
"error": "操作失败"
},
"auth": {
"login": {
"title": "登录",
"username": "用户名",
"password": "密码",
"submit": "登录",
"success": "登录成功",
"failed": "用户名或密码错误"
}
},
"nav": {
"home": "首页",
"users": "用户管理",
"roles": "角色管理",
"organizations": "组织管理",
"workflow": "工作流",
"messages": "消息中心",
"settings": "系统设置",
"plugins": "插件管理"
}
}

View File

@@ -1,5 +1,6 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './i18n'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'