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:
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
60
apps/web/pnpm-lock.yaml
generated
60
apps/web/pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,25 +13,38 @@ 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);
|
||||||
|
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: '/api/v1',
|
||||||
|
timeout: 10000,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
adapter: (config) => {
|
||||||
|
// GET 请求检查缓存
|
||||||
|
if (config.method === 'get' && config.url) {
|
||||||
|
const key = getCacheKey(config);
|
||||||
|
const entry = requestCache.get(key);
|
||||||
|
if (entry && Date.now() - entry.timestamp < CACHE_TTL) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: entry.data,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK (cached)',
|
||||||
|
headers: {} as any,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultAdapter(config);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor: attach access token
|
||||||
client.interceptors.request.use((config) => {
|
client.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem('access_token');
|
const token = localStorage.getItem('access_token');
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET 请求检查缓存
|
|
||||||
if (config.method === 'get' && config.url) {
|
|
||||||
const key = getCacheKey(config);
|
|
||||||
const entry = requestCache.get(key);
|
|
||||||
if (entry && Date.now() - entry.timestamp < CACHE_TTL) {
|
|
||||||
const source = axios.CancelToken.source();
|
|
||||||
config.cancelToken = source.token;
|
|
||||||
// 通过适配器返回缓存数据
|
|
||||||
source.cancel(JSON.stringify({ __cached: true, data: entry.data }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
8
apps/web/src/api/errors.ts
Normal file
8
apps/web/src/api/errors.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
7
apps/web/src/api/types.ts
Normal file
7
apps/web/src/api/types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
total_pages: number;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
54
apps/web/src/components/ErrorBoundary.tsx
Normal file
54
apps/web/src/components/ErrorBoundary.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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 };
|
||||||
|
}
|
||||||
12
apps/web/src/i18n/index.ts
Normal file
12
apps/web/src/i18n/index.ts
Normal 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;
|
||||||
34
apps/web/src/i18n/locales/zh-CN.json
Normal file
34
apps/web/src/i18n/locales/zh-CN.json
Normal 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": "插件管理"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user