create-pardx-scaffold 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/.cursor/worktrees.json +37 -0
- package/template/.dockerignore +49 -0
- package/template/.mcp.json +26 -0
- package/template/.nvmrc +1 -0
- package/template/CLAUDE.md +85 -0
- package/template/apps/api/libs/domain/services/index.ts +7 -0
- package/template/apps/api/libs/{infra/shared-services → domain/services}/ip-info/ip-info.module.ts +2 -0
- package/template/apps/api/libs/{infra/shared-services → domain/services}/ip-info/ip-info.service.ts +2 -0
- package/template/apps/api/libs/infra/clients/internal/file-storage/dto/file.dto.ts +1 -1
- package/template/apps/api/libs/infra/common/ts-rest/response.helper.ts +9 -1
- package/template/apps/api/libs/infra/shared-services/email/email.module.ts +0 -2
- package/template/apps/api/libs/infra/shared-services/file-storage/bucket-resolver.ts +1 -1
- package/template/apps/api/libs/infra/shared-services/file-storage/file-storage.module.ts +1 -1
- package/template/apps/api/libs/infra/shared-services/sms/sms.module.ts +0 -2
- package/template/apps/api/libs/infra/shared-services/uploader/uploader.service.ts +4 -4
- package/template/apps/api/package.json +15 -15
- package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
- package/template/apps/api/src/app.module.ts +5 -1
- package/template/apps/api/src/modules/uploader/uploader.controller.ts +305 -0
- package/template/apps/api/src/modules/uploader/uploader.module.ts +17 -0
- package/template/apps/web/.env.example +6 -4
- package/template/apps/web/components/error-boundary.tsx +166 -0
- package/template/apps/web/components/index.ts +10 -0
- package/template/apps/web/components.json +20 -0
- package/template/apps/web/config.ts +115 -0
- package/template/apps/web/eslint.config.mjs +4 -0
- package/template/apps/web/lib/api/avatar-upload.ts +1 -0
- package/template/apps/web/lib/api/contracts/client.ts +51 -30
- package/template/apps/web/lib/api/contracts/hooks/index.ts +0 -3
- package/template/apps/web/lib/api/contracts/hooks/notification.ts +42 -124
- package/template/apps/web/lib/api.ts +24 -1
- package/template/apps/web/lib/dynamic-import.tsx +121 -0
- package/template/apps/web/lib/logger.ts +113 -0
- package/template/apps/web/lib/upload/api.ts +37 -105
- package/template/apps/web/lib/upload/batch-uploader.ts +7 -74
- package/template/apps/web/lib/upload/uploader.ts +10 -74
- package/template/apps/web/locales/zh-CN/assessment.json +5 -0
- package/template/apps/web/locales/zh-CN/chat.json +6 -0
- package/template/apps/web/locales/zh-CN/common.json +38 -0
- package/template/apps/web/locales/zh-CN/creative.json +5 -0
- package/template/apps/web/locales/zh-CN/daily-challenge.json +6 -0
- package/template/apps/web/locales/zh-CN/errors.json +16 -0
- package/template/apps/web/locales/zh-CN/forms.json +18 -0
- package/template/apps/web/locales/zh-CN/memory.json +5 -0
- package/template/apps/web/locales/zh-CN/navigation.json +12 -0
- package/template/apps/web/locales/zh-CN/recommendation.json +5 -0
- package/template/apps/web/locales/zh-CN/recruitment.json +5 -0
- package/template/apps/web/locales/zh-CN/settings.json +7 -0
- package/template/apps/web/locales/zh-CN/subscription.json +6 -0
- package/template/apps/web/locales/zh-CN/validation.json +8 -0
- package/template/apps/web/package.json +14 -15
- package/template/apps/web/postcss.config.mjs +1 -0
- package/template/apps/web/proxy.ts +102 -0
- package/template/apps/web/public/logo.svg +21 -0
- package/template/apps/web/vitest.config.ts +69 -0
- package/template/apps/web/vitest.setup.ts +80 -0
- package/template/package.json +7 -7
- package/template/packages/constants/package.json +3 -1
- package/template/packages/constants/tsconfig.build.esm.json +8 -0
- package/template/packages/contracts/package.json +2 -2
- package/template/packages/contracts/src/schemas/uploader.schema.ts +33 -10
- package/template/packages/ui/.storybook/main.ts +28 -0
- package/template/packages/ui/.storybook/preview.ts +40 -0
- package/template/packages/ui/eslint.config.js +3 -0
- package/template/packages/ui/package.json +15 -2
- package/template/packages/ui/src/components/button.stories.tsx +171 -0
- package/template/packages/ui/src/styles/globals.css +1 -1
- package/template/packages/ui/tsconfig.json +1 -1
- package/template/packages/utils/package.json +2 -2
- package/template/packages/utils/tsconfig.build.esm.json +8 -0
- package/template/packages/validators/package.json +1 -1
- package/template/pnpm-lock.yaml +2263 -999
- package/template/scripts/export-scaffold-for-create.js +65 -0
- package/template/apps/api/libs/infra/utils/download.ts +0 -21
- package/template/apps/web/lib/api/client.ts +0 -649
- package/template/apps/web/lib/audio-buffer-queue.ts +0 -273
- package/template/apps/web/lib/upload/folder-utils.ts +0 -295
- /package/template/apps/api/libs/{infra/shared-services → domain/services}/ip-info/index.ts +0 -0
|
@@ -41,6 +41,54 @@ import {
|
|
|
41
41
|
|
|
42
42
|
const API_BASE_URL = API_CONFIG.baseUrl;
|
|
43
43
|
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// 错误消息去重机制(模块级别缓存)
|
|
46
|
+
// 在短时间内(2秒)相同的错误消息只显示一次
|
|
47
|
+
// ============================================================================
|
|
48
|
+
const errorMessageCache = new Map<string, number>();
|
|
49
|
+
const ERROR_DEDUP_INTERVAL = 2000; // 2秒
|
|
50
|
+
const MAX_CACHE_SIZE = 50; // 最大缓存数量
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 清理过期的错误缓存
|
|
54
|
+
*/
|
|
55
|
+
const cleanupErrorCache = () => {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
for (const [key, timestamp] of errorMessageCache.entries()) {
|
|
58
|
+
if (now - timestamp >= ERROR_DEDUP_INTERVAL) {
|
|
59
|
+
errorMessageCache.delete(key);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// 如果清理后仍超过限制,删除最旧的项
|
|
63
|
+
if (errorMessageCache.size > MAX_CACHE_SIZE) {
|
|
64
|
+
const oldestKey = errorMessageCache.keys().next().value;
|
|
65
|
+
if (oldestKey) errorMessageCache.delete(oldestKey);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 检查错误消息是否应该被去重(跳过显示)
|
|
71
|
+
*/
|
|
72
|
+
const shouldDeduplicateError = (errorKey: string): boolean => {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const lastShown = errorMessageCache.get(errorKey);
|
|
75
|
+
|
|
76
|
+
// 如果在去重时间窗口内已经显示过相同的错误,则跳过
|
|
77
|
+
if (lastShown && now - lastShown < ERROR_DEDUP_INTERVAL) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 记录当前错误消息的显示时间
|
|
82
|
+
errorMessageCache.set(errorKey, now);
|
|
83
|
+
|
|
84
|
+
// 清理过期缓存
|
|
85
|
+
if (errorMessageCache.size > MAX_CACHE_SIZE) {
|
|
86
|
+
cleanupErrorCache();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return false;
|
|
90
|
+
};
|
|
91
|
+
|
|
44
92
|
/**
|
|
45
93
|
* Base fetch function with standard headers (no auth check)
|
|
46
94
|
* Used for public endpoints like login, register
|
|
@@ -136,13 +184,7 @@ const baseFetch = async (args: ApiFetcherArgs, requireAuth: boolean = true) => {
|
|
|
136
184
|
throw new VersionMismatchError(minBuild);
|
|
137
185
|
}
|
|
138
186
|
|
|
139
|
-
//
|
|
140
|
-
// 在短时间内(2秒)相同的错误消息只显示一次
|
|
141
|
-
const errorMessageCache = new Map<string, number>();
|
|
142
|
-
const ERROR_DEDUP_INTERVAL = 2000; // 2秒
|
|
143
|
-
const MAX_CACHE_SIZE = 50; // 最大缓存数量
|
|
144
|
-
|
|
145
|
-
// Global error handling
|
|
187
|
+
// Global error handling with deduplication
|
|
146
188
|
const handleError = (errorMsg: string, statusCode: number) => {
|
|
147
189
|
if (typeof window === 'undefined') return;
|
|
148
190
|
|
|
@@ -150,42 +192,21 @@ const baseFetch = async (args: ApiFetcherArgs, requireAuth: boolean = true) => {
|
|
|
150
192
|
|
|
151
193
|
// 如果已经在登录页面,不需要全局错误处理,由登录组件自己处理
|
|
152
194
|
if (pathname === '/login' || pathname.endsWith('/login')) {
|
|
153
|
-
// 登录页面的错误(如密码错误、401 等)由登录组件自己处理,不显示 toast
|
|
154
195
|
return;
|
|
155
196
|
}
|
|
156
197
|
|
|
157
198
|
// 创建错误消息的唯一标识(包含消息内容和状态码)
|
|
158
199
|
const errorKey = `${statusCode}:${errorMsg}`;
|
|
159
|
-
const now = Date.now();
|
|
160
|
-
const lastShown = errorMessageCache.get(errorKey);
|
|
161
200
|
|
|
162
|
-
//
|
|
163
|
-
if (
|
|
201
|
+
// 使用模块级别的去重机制
|
|
202
|
+
if (shouldDeduplicateError(errorKey)) {
|
|
164
203
|
return;
|
|
165
204
|
}
|
|
166
205
|
|
|
167
|
-
// 记录当前错误消息的显示时间
|
|
168
|
-
errorMessageCache.set(errorKey, now);
|
|
169
|
-
|
|
170
|
-
// 清理过期缓存:达到最大数量时清理所有过期项
|
|
171
|
-
if (errorMessageCache.size > MAX_CACHE_SIZE) {
|
|
172
|
-
for (const [key, timestamp] of errorMessageCache.entries()) {
|
|
173
|
-
if (now - timestamp >= ERROR_DEDUP_INTERVAL) {
|
|
174
|
-
errorMessageCache.delete(key);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
// 如果清理后仍超过限制,删除最旧的项
|
|
178
|
-
if (errorMessageCache.size > MAX_CACHE_SIZE) {
|
|
179
|
-
const oldestKey = errorMessageCache.keys().next().value;
|
|
180
|
-
if (oldestKey) errorMessageCache.delete(oldestKey);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
206
|
// Handle 401 Unauthorized - clear storage and redirect to login
|
|
185
207
|
if (statusCode === 401) {
|
|
186
208
|
clearToken();
|
|
187
209
|
toast.error(errorMsg || '登录已过期,请重新登录');
|
|
188
|
-
// 重定向到登录页(公开页面在上面已经提前返回,不会执行到这里)
|
|
189
210
|
window.location.href = '/login';
|
|
190
211
|
} else {
|
|
191
212
|
// Show error toast for other error codes
|
|
@@ -4,62 +4,20 @@
|
|
|
4
4
|
* Notification API Hooks
|
|
5
5
|
* 基于 ts-rest 契约的通知 API Hooks
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* @example
|
|
12
|
-
* ```tsx
|
|
13
|
-
* import {
|
|
14
|
-
* useNotifications,
|
|
15
|
-
* useUnreadNotificationCount,
|
|
16
|
-
* useMarkNotificationRead,
|
|
17
|
-
* useMarkAllNotificationsRead,
|
|
18
|
-
* } from '@/lib/api/contracts/hooks/notification';
|
|
19
|
-
*
|
|
20
|
-
* function NotificationPanel() {
|
|
21
|
-
* const [open, setOpen] = useState(false);
|
|
22
|
-
* // 懒加载:仅当面板打开时加载通知列表
|
|
23
|
-
* const { data, isLoading } = useNotifications({ limit: 20 }, { enabled: open });
|
|
24
|
-
* // 未读数量始终加载,用于显示徽章
|
|
25
|
-
* const { data: unreadCount } = useUnreadNotificationCount();
|
|
26
|
-
* const markReadMutation = useMarkNotificationRead();
|
|
27
|
-
* const markAllReadMutation = useMarkAllNotificationsRead();
|
|
28
|
-
*
|
|
29
|
-
* return (
|
|
30
|
-
* <div>
|
|
31
|
-
* <p>未读: {unreadCount?.total}</p>
|
|
32
|
-
* {data?.list.map(notification => (
|
|
33
|
-
* <div key={notification.id} onClick={() => {
|
|
34
|
-
* markReadMutation.mutate({ notificationIds: [notification.id] });
|
|
35
|
-
* }}>
|
|
36
|
-
* {notification.content.type}
|
|
37
|
-
* </div>
|
|
38
|
-
* ))}
|
|
39
|
-
* <button onClick={() => markAllReadMutation.mutate({})}>
|
|
40
|
-
* 全部标为已读
|
|
41
|
-
* </button>
|
|
42
|
-
* </div>
|
|
43
|
-
* );
|
|
44
|
-
* }
|
|
45
|
-
* ```
|
|
7
|
+
* 注意:此文件是脚手架占位符。
|
|
8
|
+
* 实际项目中需要在 @repo/contracts 中定义 notificationContract,
|
|
9
|
+
* 并在 client.ts 中导出 notificationApi 和 notificationClient。
|
|
46
10
|
*/
|
|
47
11
|
|
|
48
|
-
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
49
|
-
import { notificationApi, notificationClient } from '../client';
|
|
50
|
-
import type {
|
|
51
|
-
MessageNotificationType,
|
|
52
|
-
GetNotificationListRequest,
|
|
53
|
-
} from '@repo/contracts';
|
|
54
|
-
|
|
55
12
|
// ============================================================================
|
|
56
13
|
// Query Keys
|
|
57
14
|
// ============================================================================
|
|
58
15
|
|
|
59
16
|
export const notificationKeys = {
|
|
60
17
|
all: ['notifications'] as const,
|
|
61
|
-
list: (params?:
|
|
18
|
+
list: (params?: Record<string, unknown>) =>
|
|
62
19
|
[...notificationKeys.all, 'list', params] as const,
|
|
20
|
+
unreadCount: () => [...notificationKeys.all, 'unreadCount'] as const,
|
|
63
21
|
};
|
|
64
22
|
|
|
65
23
|
// ============================================================================
|
|
@@ -67,7 +25,7 @@ export const notificationKeys = {
|
|
|
67
25
|
// ============================================================================
|
|
68
26
|
|
|
69
27
|
export interface NotificationsParams {
|
|
70
|
-
type?:
|
|
28
|
+
type?: string;
|
|
71
29
|
isRead?: boolean;
|
|
72
30
|
limit?: number;
|
|
73
31
|
page?: number;
|
|
@@ -79,102 +37,62 @@ export interface NotificationsOptions {
|
|
|
79
37
|
}
|
|
80
38
|
|
|
81
39
|
// ============================================================================
|
|
82
|
-
//
|
|
40
|
+
// Placeholder Hooks
|
|
41
|
+
// 这些 hooks 是占位符,需要在实际项目中实现
|
|
83
42
|
// ============================================================================
|
|
84
43
|
|
|
85
44
|
/**
|
|
86
|
-
* 获取通知列表
|
|
87
|
-
*
|
|
88
|
-
* 使用 React Query 原生 useQuery 实现 enabled 选项,
|
|
89
|
-
* 支持懒加载模式,仅在需要时加载数据
|
|
90
|
-
*
|
|
91
|
-
* @param params - 查询参数(筛选条件和分页)
|
|
92
|
-
* @param options - 可选配置项,支持 enabled 懒加载
|
|
93
|
-
*
|
|
94
|
-
* @example
|
|
95
|
-
* ```tsx
|
|
96
|
-
* // 懒加载:仅当面板打开时才加载列表
|
|
97
|
-
* const { data, isLoading, refetch } = useNotifications(
|
|
98
|
-
* { limit: 20 },
|
|
99
|
-
* { enabled: isPanelOpen }
|
|
100
|
-
* );
|
|
101
|
-
* ```
|
|
45
|
+
* 获取通知列表 (占位符)
|
|
46
|
+
* TODO: 实现 notificationContract 后启用
|
|
102
47
|
*/
|
|
103
48
|
export function useNotifications(
|
|
104
|
-
|
|
105
|
-
|
|
49
|
+
_params?: NotificationsParams,
|
|
50
|
+
_options?: NotificationsOptions,
|
|
106
51
|
) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
52
|
+
// 占位符实现
|
|
53
|
+
return {
|
|
54
|
+
data: undefined,
|
|
55
|
+
isLoading: false,
|
|
56
|
+
error: null,
|
|
57
|
+
refetch: () => Promise.resolve(),
|
|
112
58
|
};
|
|
113
|
-
|
|
114
|
-
const isEnabled = options?.enabled ?? true;
|
|
115
|
-
|
|
116
|
-
// 使用 React Query 原生 useQuery,正确支持 enabled 选项
|
|
117
|
-
return useQuery({
|
|
118
|
-
queryKey: notificationKeys.list(queryParams),
|
|
119
|
-
queryFn: async () => {
|
|
120
|
-
const response = await notificationClient.getList({
|
|
121
|
-
query: queryParams,
|
|
122
|
-
});
|
|
123
|
-
return response;
|
|
124
|
-
},
|
|
125
|
-
staleTime: 30 * 1000, // 30 seconds
|
|
126
|
-
enabled: isEnabled,
|
|
127
|
-
});
|
|
128
59
|
}
|
|
129
60
|
|
|
130
|
-
// ============================================================================
|
|
131
|
-
// Mutation Hooks
|
|
132
|
-
// ============================================================================
|
|
133
|
-
|
|
134
61
|
/**
|
|
135
|
-
* 标记通知已读
|
|
62
|
+
* 标记通知已读 (占位符)
|
|
63
|
+
* TODO: 实现 notificationContract 后启用
|
|
136
64
|
*/
|
|
137
65
|
export function useMarkNotificationRead() {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
queryKey: notificationKeys.all,
|
|
145
|
-
});
|
|
146
|
-
},
|
|
147
|
-
});
|
|
66
|
+
return {
|
|
67
|
+
mutate: (_params: { notificationIds: string[] }) => {},
|
|
68
|
+
mutateAsync: (_params: { notificationIds: string[] }) => Promise.resolve(),
|
|
69
|
+
isLoading: false,
|
|
70
|
+
isPending: false,
|
|
71
|
+
};
|
|
148
72
|
}
|
|
149
73
|
|
|
150
74
|
/**
|
|
151
|
-
* 标记全部通知已读
|
|
75
|
+
* 标记全部通知已读 (占位符)
|
|
76
|
+
* TODO: 实现 notificationContract 后启用
|
|
152
77
|
*/
|
|
153
78
|
export function useMarkAllNotificationsRead() {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
queryKey: notificationKeys.all,
|
|
161
|
-
});
|
|
162
|
-
},
|
|
163
|
-
});
|
|
79
|
+
return {
|
|
80
|
+
mutate: () => {},
|
|
81
|
+
mutateAsync: () => Promise.resolve(),
|
|
82
|
+
isLoading: false,
|
|
83
|
+
isPending: false,
|
|
84
|
+
};
|
|
164
85
|
}
|
|
165
86
|
|
|
166
87
|
/**
|
|
167
|
-
* 删除通知
|
|
88
|
+
* 删除通知 (占位符)
|
|
89
|
+
* TODO: 实现 notificationContract 后启用
|
|
168
90
|
*/
|
|
169
91
|
export function useDeleteNotification() {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
queryKey: notificationKeys.all,
|
|
177
|
-
});
|
|
178
|
-
},
|
|
179
|
-
});
|
|
92
|
+
return {
|
|
93
|
+
mutate: (_params: { notificationId: string }) => {},
|
|
94
|
+
mutateAsync: (_params: { notificationId: string }) => Promise.resolve(),
|
|
95
|
+
isLoading: false,
|
|
96
|
+
isPending: false,
|
|
97
|
+
};
|
|
180
98
|
}
|
|
@@ -44,6 +44,9 @@ export interface TokenData {
|
|
|
44
44
|
// Token Management
|
|
45
45
|
// ============================================================================
|
|
46
46
|
|
|
47
|
+
// Token 刷新队列:防止并发请求时多次刷新 token
|
|
48
|
+
let refreshPromise: Promise<LoginData> | null = null;
|
|
49
|
+
|
|
47
50
|
// 获取存储的 token(使用新的存储模块)
|
|
48
51
|
export function getToken(): string | null {
|
|
49
52
|
return getAccessToken();
|
|
@@ -121,8 +124,10 @@ export async function login(credentials: LoginRequest): Promise<LoginData> {
|
|
|
121
124
|
/**
|
|
122
125
|
* 刷新 Token API (ts-rest)
|
|
123
126
|
* 使用 signClient.refreshToken
|
|
127
|
+
*
|
|
128
|
+
* 注意:此函数内部使用,外部应使用 ensureValidToken
|
|
124
129
|
*/
|
|
125
|
-
|
|
130
|
+
async function doRefreshToken(): Promise<LoginData> {
|
|
126
131
|
const refresh = getRefreshToken();
|
|
127
132
|
if (!refresh) {
|
|
128
133
|
throw new Error('没有可用的 refresh token');
|
|
@@ -149,6 +154,24 @@ export async function refreshToken(): Promise<LoginData> {
|
|
|
149
154
|
return data;
|
|
150
155
|
}
|
|
151
156
|
|
|
157
|
+
/**
|
|
158
|
+
* 刷新 Token(带队列机制)
|
|
159
|
+
* 防止并发请求时多次刷新 token
|
|
160
|
+
*/
|
|
161
|
+
export async function refreshToken(): Promise<LoginData> {
|
|
162
|
+
// 如果已有刷新请求在进行中,等待它完成
|
|
163
|
+
if (refreshPromise) {
|
|
164
|
+
return refreshPromise;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 创建刷新 Promise 并共享
|
|
168
|
+
refreshPromise = doRefreshToken().finally(() => {
|
|
169
|
+
refreshPromise = null;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return refreshPromise;
|
|
173
|
+
}
|
|
174
|
+
|
|
152
175
|
/**
|
|
153
176
|
* 确保 token 有效(如果过期则自动刷新)
|
|
154
177
|
*/
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 动态导入工具
|
|
5
|
+
*
|
|
6
|
+
* 提供代码分割和懒加载的工具函数
|
|
7
|
+
* 用于优化首屏加载性能
|
|
8
|
+
*/
|
|
9
|
+
import dynamic from 'next/dynamic';
|
|
10
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 加载状态组件
|
|
14
|
+
*/
|
|
15
|
+
function DefaultLoadingComponent() {
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex items-center justify-center p-4">
|
|
18
|
+
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-blue-600" />
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 创建懒加载组件
|
|
25
|
+
*
|
|
26
|
+
* @param importFn - 动态导入函数
|
|
27
|
+
* @param options - 配置选项
|
|
28
|
+
* @returns 懒加载的组件
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* // 基本用法
|
|
33
|
+
* const HeavyChart = createLazyComponent(() => import('@/components/chart'));
|
|
34
|
+
*
|
|
35
|
+
* // 带自定义加载状态
|
|
36
|
+
* const Editor = createLazyComponent(
|
|
37
|
+
* () => import('@/components/editor'),
|
|
38
|
+
* { loading: <EditorSkeleton /> }
|
|
39
|
+
* );
|
|
40
|
+
*
|
|
41
|
+
* // 禁用 SSR(用于仅客户端组件)
|
|
42
|
+
* const ClientOnlyMap = createLazyComponent(
|
|
43
|
+
* () => import('@/components/map'),
|
|
44
|
+
* { ssr: false }
|
|
45
|
+
* );
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function createLazyComponent<P extends object>(
|
|
49
|
+
importFn: () => Promise<{ default: ComponentType<P> }>,
|
|
50
|
+
options: {
|
|
51
|
+
loading?: ReactNode;
|
|
52
|
+
ssr?: boolean;
|
|
53
|
+
} = {},
|
|
54
|
+
) {
|
|
55
|
+
const { loading, ssr = true } = options;
|
|
56
|
+
|
|
57
|
+
return dynamic(importFn, {
|
|
58
|
+
loading: () => (loading ? <>{loading}</> : <DefaultLoadingComponent />),
|
|
59
|
+
ssr,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 预加载组件
|
|
65
|
+
*
|
|
66
|
+
* 在用户可能需要组件之前预先加载
|
|
67
|
+
* 例如:鼠标悬停在链接上时预加载目标页面的组件
|
|
68
|
+
*
|
|
69
|
+
* @param importFn - 动态导入函数
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```tsx
|
|
73
|
+
* const loadEditor = () => import('@/components/editor');
|
|
74
|
+
*
|
|
75
|
+
* // 在鼠标悬停时预加载
|
|
76
|
+
* <button onMouseEnter={() => preloadComponent(loadEditor)}>
|
|
77
|
+
* 打开编辑器
|
|
78
|
+
* </button>
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export function preloadComponent<T>(
|
|
82
|
+
importFn: () => Promise<T>,
|
|
83
|
+
): Promise<T> | void {
|
|
84
|
+
if (typeof window !== 'undefined') {
|
|
85
|
+
return importFn();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 常用的懒加载组件示例
|
|
91
|
+
*
|
|
92
|
+
* 这些组件通常较大,适合懒加载:
|
|
93
|
+
* - 富文本编辑器
|
|
94
|
+
* - 图表库
|
|
95
|
+
* - 地图组件
|
|
96
|
+
* - 文件预览器
|
|
97
|
+
* - 代码编辑器
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
// 示例:懒加载图表组件(如果存在)
|
|
101
|
+
// export const LazyChart = createLazyComponent(
|
|
102
|
+
// () => import('@/components/chart'),
|
|
103
|
+
// { ssr: false }
|
|
104
|
+
// );
|
|
105
|
+
|
|
106
|
+
// 示例:懒加载编辑器组件(如果存在)
|
|
107
|
+
// export const LazyEditor = createLazyComponent(
|
|
108
|
+
// () => import('@/components/editor'),
|
|
109
|
+
// { ssr: false }
|
|
110
|
+
// );
|
|
111
|
+
|
|
112
|
+
// 示例:懒加载文件预览器(如果存在)
|
|
113
|
+
// export const LazyFilePreview = createLazyComponent(
|
|
114
|
+
// () => import('@/components/file-preview'),
|
|
115
|
+
// { ssr: false }
|
|
116
|
+
// );
|
|
117
|
+
|
|
118
|
+
export default {
|
|
119
|
+
createLazyComponent,
|
|
120
|
+
preloadComponent,
|
|
121
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontend Logger
|
|
3
|
+
*
|
|
4
|
+
* 浏览器兼容的日志库,替代 console.log
|
|
5
|
+
* 支持日志级别控制和生产环境静默
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
9
|
+
|
|
10
|
+
interface LoggerConfig {
|
|
11
|
+
level: LogLevel;
|
|
12
|
+
prefix?: string;
|
|
13
|
+
enableInProduction?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const LOG_LEVELS: Record<LogLevel, number> = {
|
|
17
|
+
debug: 0,
|
|
18
|
+
info: 1,
|
|
19
|
+
warn: 2,
|
|
20
|
+
error: 3,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
class Logger {
|
|
24
|
+
private level: LogLevel;
|
|
25
|
+
private prefix: string;
|
|
26
|
+
private enableInProduction: boolean;
|
|
27
|
+
|
|
28
|
+
constructor(config: Partial<LoggerConfig> = {}) {
|
|
29
|
+
this.level = config.level ?? this.getDefaultLevel();
|
|
30
|
+
this.prefix = config.prefix ?? '[App]';
|
|
31
|
+
this.enableInProduction = config.enableInProduction ?? false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private getDefaultLevel(): LogLevel {
|
|
35
|
+
if (typeof window === 'undefined') {
|
|
36
|
+
return 'info';
|
|
37
|
+
}
|
|
38
|
+
return process.env.NODE_ENV === 'production' ? 'warn' : 'debug';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private shouldLog(level: LogLevel): boolean {
|
|
42
|
+
// 在生产环境中,除非明确启用,否则只记录 warn 和 error
|
|
43
|
+
if (
|
|
44
|
+
process.env.NODE_ENV === 'production' &&
|
|
45
|
+
!this.enableInProduction &&
|
|
46
|
+
LOG_LEVELS[level] < LOG_LEVELS.warn
|
|
47
|
+
) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[this.level];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private formatMessage(level: LogLevel, message: string): string {
|
|
54
|
+
const timestamp = new Date().toISOString();
|
|
55
|
+
return `${timestamp} ${this.prefix} [${level.toUpperCase()}] ${message}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
debug(message: string, ...args: unknown[]): void {
|
|
59
|
+
if (this.shouldLog('debug')) {
|
|
60
|
+
console.debug(this.formatMessage('debug', message), ...args);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
info(message: string, ...args: unknown[]): void {
|
|
65
|
+
if (this.shouldLog('info')) {
|
|
66
|
+
console.info(this.formatMessage('info', message), ...args);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
warn(message: string, ...args: unknown[]): void {
|
|
71
|
+
if (this.shouldLog('warn')) {
|
|
72
|
+
console.warn(this.formatMessage('warn', message), ...args);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
error(message: string, ...args: unknown[]): void {
|
|
77
|
+
if (this.shouldLog('error')) {
|
|
78
|
+
console.error(this.formatMessage('error', message), ...args);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 设置日志级别
|
|
84
|
+
*/
|
|
85
|
+
setLevel(level: LogLevel): void {
|
|
86
|
+
this.level = level;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 创建带有自定义前缀的子 logger
|
|
91
|
+
*/
|
|
92
|
+
child(prefix: string): Logger {
|
|
93
|
+
return new Logger({
|
|
94
|
+
level: this.level,
|
|
95
|
+
prefix: `${this.prefix}:${prefix}`,
|
|
96
|
+
enableInProduction: this.enableInProduction,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 默认 logger 实例
|
|
102
|
+
export const logger = new Logger();
|
|
103
|
+
|
|
104
|
+
// 创建模块专用 logger
|
|
105
|
+
export const createLogger = (prefix: string): Logger => {
|
|
106
|
+
return logger.child(prefix);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// 导出类型
|
|
110
|
+
export type { LogLevel, LoggerConfig };
|
|
111
|
+
export { Logger };
|
|
112
|
+
|
|
113
|
+
export default logger;
|