create-pardx-scaffold 0.1.2 → 0.1.5
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/apps/api/libs/infra/clients/internal/ip-info/dto/ip-info.dto.ts +15 -0
- package/template/apps/api/libs/infra/clients/internal/ip-info/index.ts +3 -0
- package/template/apps/api/libs/infra/clients/internal/ip-info/ip-info.client.ts +57 -0
- package/template/apps/api/libs/infra/clients/internal/ip-info/ip-info.module.ts +17 -0
- package/template/apps/api/libs/infra/common/ts-rest/response.helper.ts +9 -1
- package/template/apps/api/libs/infra/shared-services/file-storage/README.md +4 -4
- package/template/apps/api/libs/infra/shared-services/file-storage/bucket-resolver.ts +4 -4
- package/template/apps/api/libs/infra/shared-services/file-storage/file-storage.module.ts +3 -3
- package/template/apps/api/libs/infra/shared-services/ip-geo/continent-mapping.ts +86 -0
- package/template/apps/api/libs/infra/shared-services/ip-geo/index.ts +37 -0
- package/template/apps/api/libs/infra/shared-services/ip-geo/ip-geo.module.ts +21 -0
- package/template/apps/api/libs/infra/shared-services/ip-geo/ip-geo.service.ts +135 -0
- package/template/apps/api/libs/infra/shared-services/uploader/index.ts +1 -1
- package/template/apps/api/libs/infra/shared-services/uploader/uploader.service.ts +14 -6
- package/template/apps/api/src/app.module.ts +3 -12
- package/template/apps/api/src/modules/uploader/uploader.controller.ts +290 -0
- package/template/apps/api/src/modules/uploader/uploader.module.ts +17 -0
- package/template/apps/web/components/index.ts +21 -0
- package/template/apps/web/components/skeletons.tsx +188 -0
- package/template/apps/web/components/suspense-utils.tsx +123 -0
- package/template/apps/web/lib/api/contracts/client.ts +4 -0
- package/template/apps/web/lib/deprecation-warning.ts +150 -0
- package/template/apps/web/lib/queries/optimistic-update.ts +204 -0
- package/template/packages/constants/src/index.ts +22 -0
- package/template/apps/api/src/modules/health/health.controller.ts +0 -13
- package/template/apps/api/src/modules/health/health.module.ts +0 -7
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API 废弃警告处理
|
|
3
|
+
*
|
|
4
|
+
* 当后端返回废弃响应头时,显示警告通知:
|
|
5
|
+
* - Deprecation: true
|
|
6
|
+
* - X-Deprecation-Message: 废弃原因
|
|
7
|
+
* - Sunset: 下线日期
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { toast } from 'sonner';
|
|
11
|
+
import {
|
|
12
|
+
DEPRECATION_HEADER,
|
|
13
|
+
DEPRECATION_MESSAGE_HEADER,
|
|
14
|
+
SUNSET_HEADER,
|
|
15
|
+
} from '@repo/constants';
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Types
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
export interface DeprecationInfo {
|
|
22
|
+
/** 废弃消息 */
|
|
23
|
+
message: string;
|
|
24
|
+
/** 下线日期 (ISO 8601) */
|
|
25
|
+
sunsetDate: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Constants
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
const DEPRECATION_SHOWN_KEY = 'pardx:deprecation_warnings_shown';
|
|
33
|
+
const TOAST_DURATION = 10000; // 10 秒
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Helper Functions
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 获取已显示警告的 API 路径集合
|
|
41
|
+
*/
|
|
42
|
+
function getShownWarnings(): Set<string> {
|
|
43
|
+
if (typeof sessionStorage === 'undefined') {
|
|
44
|
+
return new Set();
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const stored = sessionStorage.getItem(DEPRECATION_SHOWN_KEY);
|
|
48
|
+
return stored ? new Set(JSON.parse(stored)) : new Set();
|
|
49
|
+
} catch {
|
|
50
|
+
return new Set();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 记录已显示警告的 API 路径
|
|
56
|
+
*/
|
|
57
|
+
function markWarningShown(path: string): void {
|
|
58
|
+
if (typeof sessionStorage === 'undefined') {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const shown = getShownWarnings();
|
|
63
|
+
shown.add(path);
|
|
64
|
+
sessionStorage.setItem(DEPRECATION_SHOWN_KEY, JSON.stringify(Array.from(shown)));
|
|
65
|
+
} catch {
|
|
66
|
+
// Ignore storage errors
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 格式化下线日期
|
|
72
|
+
*/
|
|
73
|
+
function formatSunsetDate(sunsetDate: string): string {
|
|
74
|
+
try {
|
|
75
|
+
const date = new Date(sunsetDate);
|
|
76
|
+
return date.toLocaleDateString('zh-CN', {
|
|
77
|
+
year: 'numeric',
|
|
78
|
+
month: 'long',
|
|
79
|
+
day: 'numeric',
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
return sunsetDate;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// Public Functions
|
|
88
|
+
// ============================================================================
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 检查响应是否包含废弃标识
|
|
92
|
+
*/
|
|
93
|
+
export function isDeprecated(headers: Headers): boolean {
|
|
94
|
+
const deprecation = headers.get(DEPRECATION_HEADER);
|
|
95
|
+
return deprecation === 'true';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 从响应头中提取废弃信息
|
|
100
|
+
*/
|
|
101
|
+
export function getDeprecationInfo(headers: Headers): DeprecationInfo | null {
|
|
102
|
+
if (!isDeprecated(headers)) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const message =
|
|
107
|
+
headers.get(DEPRECATION_MESSAGE_HEADER) || '此 API 即将废弃,请尽快迁移';
|
|
108
|
+
const sunsetDate = headers.get(SUNSET_HEADER);
|
|
109
|
+
|
|
110
|
+
return { message, sunsetDate };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 检查并显示废弃警告
|
|
115
|
+
*
|
|
116
|
+
* @param headers 响应头
|
|
117
|
+
* @param path API 路径
|
|
118
|
+
*/
|
|
119
|
+
export function checkDeprecationWarning(headers: Headers, path: string): void {
|
|
120
|
+
if (typeof window === 'undefined') {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const info = getDeprecationInfo(headers);
|
|
125
|
+
if (!info) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 检查是否已显示过该路径的警告
|
|
130
|
+
const shown = getShownWarnings();
|
|
131
|
+
if (shown.has(path)) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 标记为已显示
|
|
136
|
+
markWarningShown(path);
|
|
137
|
+
|
|
138
|
+
// 构建警告消息
|
|
139
|
+
let description = info.message;
|
|
140
|
+
if (info.sunsetDate) {
|
|
141
|
+
description += `\n下线日期: ${formatSunsetDate(info.sunsetDate)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 显示警告 Toast
|
|
145
|
+
toast.warning('API 即将废弃', {
|
|
146
|
+
description,
|
|
147
|
+
duration: TOAST_DURATION,
|
|
148
|
+
dismissible: true,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 乐观更新工具
|
|
3
|
+
*
|
|
4
|
+
* 提供 React Query 乐观更新的工具函数和示例
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { QueryClient } from '@tanstack/react-query';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 乐观更新配置
|
|
11
|
+
*/
|
|
12
|
+
interface OptimisticUpdateConfig<TData, TVariables> {
|
|
13
|
+
/** Query Key */
|
|
14
|
+
queryKey: readonly unknown[];
|
|
15
|
+
/** 乐观更新函数 - 返回更新后的数据 */
|
|
16
|
+
updater: (oldData: TData | undefined, variables: TVariables) => TData;
|
|
17
|
+
/** 回滚函数(可选) */
|
|
18
|
+
onRollback?: (context: { previousData: TData | undefined }) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 创建乐观更新的 mutation 配置
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* const updateTodo = useMutation({
|
|
27
|
+
* mutationFn: api.todo.update,
|
|
28
|
+
* ...createOptimisticUpdate(queryClient, {
|
|
29
|
+
* queryKey: ['todos'],
|
|
30
|
+
* updater: (oldData, variables) => {
|
|
31
|
+
* return oldData?.map(todo =>
|
|
32
|
+
* todo.id === variables.id ? { ...todo, ...variables } : todo
|
|
33
|
+
* );
|
|
34
|
+
* },
|
|
35
|
+
* }),
|
|
36
|
+
* });
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function createOptimisticUpdate<TData, TVariables, TError = unknown>(
|
|
40
|
+
queryClient: QueryClient,
|
|
41
|
+
config: OptimisticUpdateConfig<TData, TVariables>,
|
|
42
|
+
) {
|
|
43
|
+
return {
|
|
44
|
+
onMutate: async (variables: TVariables) => {
|
|
45
|
+
// 取消正在进行的查询
|
|
46
|
+
await queryClient.cancelQueries({ queryKey: config.queryKey });
|
|
47
|
+
|
|
48
|
+
// 保存之前的数据
|
|
49
|
+
const previousData = queryClient.getQueryData<TData>(config.queryKey);
|
|
50
|
+
|
|
51
|
+
// 乐观更新
|
|
52
|
+
queryClient.setQueryData<TData>(config.queryKey, (oldData) =>
|
|
53
|
+
config.updater(oldData, variables),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// 返回上下文用于回滚
|
|
57
|
+
return { previousData };
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
onError: (
|
|
61
|
+
_error: TError,
|
|
62
|
+
_variables: TVariables,
|
|
63
|
+
context: { previousData: TData | undefined } | undefined,
|
|
64
|
+
) => {
|
|
65
|
+
// 回滚到之前的数据
|
|
66
|
+
if (context?.previousData !== undefined) {
|
|
67
|
+
queryClient.setQueryData(config.queryKey, context.previousData);
|
|
68
|
+
}
|
|
69
|
+
config.onRollback?.(context || { previousData: undefined });
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
onSettled: () => {
|
|
73
|
+
// 重新获取数据以确保同步
|
|
74
|
+
queryClient.invalidateQueries({ queryKey: config.queryKey });
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 列表项乐观更新 - 更新单个项
|
|
81
|
+
*/
|
|
82
|
+
export function createListItemOptimisticUpdate<
|
|
83
|
+
TItem extends { id: string | number },
|
|
84
|
+
TVariables extends { id: string | number },
|
|
85
|
+
>(
|
|
86
|
+
queryClient: QueryClient,
|
|
87
|
+
queryKey: readonly unknown[],
|
|
88
|
+
options?: {
|
|
89
|
+
onRollback?: () => void;
|
|
90
|
+
},
|
|
91
|
+
) {
|
|
92
|
+
return createOptimisticUpdate<TItem[], TVariables>(queryClient, {
|
|
93
|
+
queryKey,
|
|
94
|
+
updater: (oldData, variables) => {
|
|
95
|
+
if (!oldData) return [];
|
|
96
|
+
return oldData.map((item) =>
|
|
97
|
+
item.id === variables.id ? { ...item, ...variables } : item,
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
onRollback: options?.onRollback,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 列表项乐观删除
|
|
106
|
+
*/
|
|
107
|
+
export function createListItemOptimisticDelete<
|
|
108
|
+
TItem extends { id: string | number },
|
|
109
|
+
>(
|
|
110
|
+
queryClient: QueryClient,
|
|
111
|
+
queryKey: readonly unknown[],
|
|
112
|
+
options?: {
|
|
113
|
+
onRollback?: () => void;
|
|
114
|
+
},
|
|
115
|
+
) {
|
|
116
|
+
return createOptimisticUpdate<TItem[], { id: string | number }>(queryClient, {
|
|
117
|
+
queryKey,
|
|
118
|
+
updater: (oldData, variables) => {
|
|
119
|
+
if (!oldData) return [];
|
|
120
|
+
return oldData.filter((item) => item.id !== variables.id);
|
|
121
|
+
},
|
|
122
|
+
onRollback: options?.onRollback,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 列表项乐观添加
|
|
128
|
+
*/
|
|
129
|
+
export function createListItemOptimisticAdd<TItem>(
|
|
130
|
+
queryClient: QueryClient,
|
|
131
|
+
queryKey: readonly unknown[],
|
|
132
|
+
options?: {
|
|
133
|
+
prepend?: boolean;
|
|
134
|
+
onRollback?: () => void;
|
|
135
|
+
},
|
|
136
|
+
) {
|
|
137
|
+
return createOptimisticUpdate<TItem[], TItem>(queryClient, {
|
|
138
|
+
queryKey,
|
|
139
|
+
updater: (oldData, newItem) => {
|
|
140
|
+
if (!oldData) return [newItem];
|
|
141
|
+
return options?.prepend
|
|
142
|
+
? [newItem, ...oldData]
|
|
143
|
+
: [...oldData, newItem];
|
|
144
|
+
},
|
|
145
|
+
onRollback: options?.onRollback,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 使用示例
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```tsx
|
|
154
|
+
* // 在组件中使用乐观更新
|
|
155
|
+
* function TodoList() {
|
|
156
|
+
* const queryClient = useQueryClient();
|
|
157
|
+
*
|
|
158
|
+
* // 更新 Todo
|
|
159
|
+
* const updateTodo = useMutation({
|
|
160
|
+
* mutationFn: (data: { id: string; completed: boolean }) =>
|
|
161
|
+
* api.todo.update(data),
|
|
162
|
+
* ...createListItemOptimisticUpdate<Todo, { id: string; completed: boolean }>(
|
|
163
|
+
* queryClient,
|
|
164
|
+
* ['todos'],
|
|
165
|
+
* ),
|
|
166
|
+
* });
|
|
167
|
+
*
|
|
168
|
+
* // 删除 Todo
|
|
169
|
+
* const deleteTodo = useMutation({
|
|
170
|
+
* mutationFn: (id: string) => api.todo.delete(id),
|
|
171
|
+
* ...createListItemOptimisticDelete<Todo>(queryClient, ['todos']),
|
|
172
|
+
* });
|
|
173
|
+
*
|
|
174
|
+
* // 添加 Todo
|
|
175
|
+
* const addTodo = useMutation({
|
|
176
|
+
* mutationFn: (data: Omit<Todo, 'id'>) => api.todo.create(data),
|
|
177
|
+
* ...createListItemOptimisticAdd<Todo>(queryClient, ['todos'], {
|
|
178
|
+
* prepend: true,
|
|
179
|
+
* }),
|
|
180
|
+
* });
|
|
181
|
+
*
|
|
182
|
+
* return (
|
|
183
|
+
* <ul>
|
|
184
|
+
* {todos.map(todo => (
|
|
185
|
+
* <li key={todo.id}>
|
|
186
|
+
* <input
|
|
187
|
+
* type="checkbox"
|
|
188
|
+
* checked={todo.completed}
|
|
189
|
+
* onChange={() => updateTodo.mutate({
|
|
190
|
+
* id: todo.id,
|
|
191
|
+
* completed: !todo.completed,
|
|
192
|
+
* })}
|
|
193
|
+
* />
|
|
194
|
+
* {todo.title}
|
|
195
|
+
* <button onClick={() => deleteTodo.mutate({ id: todo.id })}>
|
|
196
|
+
* 删除
|
|
197
|
+
* </button>
|
|
198
|
+
* </li>
|
|
199
|
+
* ))}
|
|
200
|
+
* </ul>
|
|
201
|
+
* );
|
|
202
|
+
* }
|
|
203
|
+
* ```
|
|
204
|
+
*/
|
|
@@ -54,6 +54,28 @@ export const DATE_FORMAT = {
|
|
|
54
54
|
ISO: 'YYYY-MM-DDTHH:mm:ss.SSSZ',
|
|
55
55
|
} as const;
|
|
56
56
|
|
|
57
|
+
// ============================================================================
|
|
58
|
+
// Deprecation Headers (废弃警告响应头)
|
|
59
|
+
// ============================================================================
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 废弃标识 Header (响应)
|
|
63
|
+
* 值为 "true" 表示该 API 已废弃
|
|
64
|
+
*/
|
|
65
|
+
export const DEPRECATION_HEADER = 'deprecation' as const;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 废弃消息 Header (响应)
|
|
69
|
+
* 包含废弃原因和迁移建议
|
|
70
|
+
*/
|
|
71
|
+
export const DEPRECATION_MESSAGE_HEADER = 'x-deprecation-message' as const;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 下线日期 Header (响应)
|
|
75
|
+
* 格式: ISO 8601 日期字符串
|
|
76
|
+
*/
|
|
77
|
+
export const SUNSET_HEADER = 'sunset' as const;
|
|
78
|
+
|
|
57
79
|
// ============================================================================
|
|
58
80
|
// API Versioning Constants
|
|
59
81
|
// ============================================================================
|