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

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

View File

@@ -1,11 +1,5 @@
import axios from 'axios';
const client = axios.create({
baseURL: '/api/v1',
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
});
// 请求缓存:短时间内相同请求复用结果
interface CacheEntry {
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 || {})}`;
}
// 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) => {
const token = localStorage.getItem('access_token');
if (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;
});
@@ -52,14 +59,6 @@ client.interceptors.response.use(
return response;
},
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;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {

View File

@@ -1,5 +1,5 @@
import client from './client';
import type { PaginatedResponse } from './users';
import type { PaginatedResponse } from './types';
export interface DictionaryItemInfo {
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 type { PaginatedResponse } from './users';
import type { PaginatedResponse } from './types';
export interface MessageTemplateInfo {
id: string;

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import client from './client';
import type { PaginatedResponse } from './users';
import type { PaginatedResponse } from './types';
export interface RoleInfo {
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 type { UserInfo } from './auth';
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
import type { PaginatedResponse } from './types';
export interface CreateUserRequest {
username: string;

View File

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

View File

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

View File

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