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.
Files changed (79) hide show
  1. package/package.json +1 -1
  2. package/template/.cursor/worktrees.json +37 -0
  3. package/template/.dockerignore +49 -0
  4. package/template/.mcp.json +26 -0
  5. package/template/.nvmrc +1 -0
  6. package/template/CLAUDE.md +85 -0
  7. package/template/apps/api/libs/domain/services/index.ts +7 -0
  8. package/template/apps/api/libs/{infra/shared-services → domain/services}/ip-info/ip-info.module.ts +2 -0
  9. package/template/apps/api/libs/{infra/shared-services → domain/services}/ip-info/ip-info.service.ts +2 -0
  10. package/template/apps/api/libs/infra/clients/internal/file-storage/dto/file.dto.ts +1 -1
  11. package/template/apps/api/libs/infra/common/ts-rest/response.helper.ts +9 -1
  12. package/template/apps/api/libs/infra/shared-services/email/email.module.ts +0 -2
  13. package/template/apps/api/libs/infra/shared-services/file-storage/bucket-resolver.ts +1 -1
  14. package/template/apps/api/libs/infra/shared-services/file-storage/file-storage.module.ts +1 -1
  15. package/template/apps/api/libs/infra/shared-services/sms/sms.module.ts +0 -2
  16. package/template/apps/api/libs/infra/shared-services/uploader/uploader.service.ts +4 -4
  17. package/template/apps/api/package.json +15 -15
  18. package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
  19. package/template/apps/api/src/app.module.ts +5 -1
  20. package/template/apps/api/src/modules/uploader/uploader.controller.ts +305 -0
  21. package/template/apps/api/src/modules/uploader/uploader.module.ts +17 -0
  22. package/template/apps/web/.env.example +6 -4
  23. package/template/apps/web/components/error-boundary.tsx +166 -0
  24. package/template/apps/web/components/index.ts +10 -0
  25. package/template/apps/web/components.json +20 -0
  26. package/template/apps/web/config.ts +115 -0
  27. package/template/apps/web/eslint.config.mjs +4 -0
  28. package/template/apps/web/lib/api/avatar-upload.ts +1 -0
  29. package/template/apps/web/lib/api/contracts/client.ts +51 -30
  30. package/template/apps/web/lib/api/contracts/hooks/index.ts +0 -3
  31. package/template/apps/web/lib/api/contracts/hooks/notification.ts +42 -124
  32. package/template/apps/web/lib/api.ts +24 -1
  33. package/template/apps/web/lib/dynamic-import.tsx +121 -0
  34. package/template/apps/web/lib/logger.ts +113 -0
  35. package/template/apps/web/lib/upload/api.ts +37 -105
  36. package/template/apps/web/lib/upload/batch-uploader.ts +7 -74
  37. package/template/apps/web/lib/upload/uploader.ts +10 -74
  38. package/template/apps/web/locales/zh-CN/assessment.json +5 -0
  39. package/template/apps/web/locales/zh-CN/chat.json +6 -0
  40. package/template/apps/web/locales/zh-CN/common.json +38 -0
  41. package/template/apps/web/locales/zh-CN/creative.json +5 -0
  42. package/template/apps/web/locales/zh-CN/daily-challenge.json +6 -0
  43. package/template/apps/web/locales/zh-CN/errors.json +16 -0
  44. package/template/apps/web/locales/zh-CN/forms.json +18 -0
  45. package/template/apps/web/locales/zh-CN/memory.json +5 -0
  46. package/template/apps/web/locales/zh-CN/navigation.json +12 -0
  47. package/template/apps/web/locales/zh-CN/recommendation.json +5 -0
  48. package/template/apps/web/locales/zh-CN/recruitment.json +5 -0
  49. package/template/apps/web/locales/zh-CN/settings.json +7 -0
  50. package/template/apps/web/locales/zh-CN/subscription.json +6 -0
  51. package/template/apps/web/locales/zh-CN/validation.json +8 -0
  52. package/template/apps/web/package.json +14 -15
  53. package/template/apps/web/postcss.config.mjs +1 -0
  54. package/template/apps/web/proxy.ts +102 -0
  55. package/template/apps/web/public/logo.svg +21 -0
  56. package/template/apps/web/vitest.config.ts +69 -0
  57. package/template/apps/web/vitest.setup.ts +80 -0
  58. package/template/package.json +7 -7
  59. package/template/packages/constants/package.json +3 -1
  60. package/template/packages/constants/tsconfig.build.esm.json +8 -0
  61. package/template/packages/contracts/package.json +2 -2
  62. package/template/packages/contracts/src/schemas/uploader.schema.ts +33 -10
  63. package/template/packages/ui/.storybook/main.ts +28 -0
  64. package/template/packages/ui/.storybook/preview.ts +40 -0
  65. package/template/packages/ui/eslint.config.js +3 -0
  66. package/template/packages/ui/package.json +15 -2
  67. package/template/packages/ui/src/components/button.stories.tsx +171 -0
  68. package/template/packages/ui/src/styles/globals.css +1 -1
  69. package/template/packages/ui/tsconfig.json +1 -1
  70. package/template/packages/utils/package.json +2 -2
  71. package/template/packages/utils/tsconfig.build.esm.json +8 -0
  72. package/template/packages/validators/package.json +1 -1
  73. package/template/pnpm-lock.yaml +2263 -999
  74. package/template/scripts/export-scaffold-for-create.js +65 -0
  75. package/template/apps/api/libs/infra/utils/download.ts +0 -21
  76. package/template/apps/web/lib/api/client.ts +0 -649
  77. package/template/apps/web/lib/audio-buffer-queue.ts +0 -273
  78. package/template/apps/web/lib/upload/folder-utils.ts +0 -295
  79. /package/template/apps/api/libs/{infra/shared-services → domain/services}/ip-info/index.ts +0 -0
@@ -1,273 +0,0 @@
1
- /**
2
- * @fileoverview 音频缓冲队列
3
- *
4
- * 用于处理 token refresh 期间的音频数据缓冲
5
- * 确保长时间录音场景下不丢失音频数据
6
- *
7
- * @module lib/audio-buffer-queue
8
- */
9
-
10
- interface AudioChunk {
11
- audioData: string; // base64 encoded
12
- sequence: number;
13
- timestamp: number;
14
- }
15
-
16
- interface AudioBufferQueueOptions {
17
- /** 最大队列长度(默认 100) */
18
- maxQueueSize?: number;
19
- /** 发送重试次数(默认 3) */
20
- maxRetries?: number;
21
- /** 重试延迟(毫秒,默认 1000) */
22
- retryDelay?: number;
23
- }
24
-
25
- /**
26
- * 音频缓冲队列
27
- *
28
- * @description
29
- * 解决 JWT token refresh 导致的音频数据丢失问题:
30
- * 1. Token 过期时,自动缓冲音频数据到队列
31
- * 2. Token 刷新完成后,按序重新发送队列中的数据
32
- * 3. 支持失败重试机制
33
- *
34
- * @example
35
- * ```typescript
36
- * const queue = new AudioBufferQueue({
37
- * maxQueueSize: 100,
38
- * maxRetries: 3,
39
- * retryDelay: 1000,
40
- * });
41
- *
42
- * // 发送音频数据(自动处理缓冲和重试)
43
- * await queue.send(
44
- * sessionId,
45
- * audioData,
46
- * sequence,
47
- * (sessionId, audioData, sequence) => apiClient.sendAudioChunk({...})
48
- * );
49
- *
50
- * // 手动触发 token refresh
51
- * await queue.handleTokenRefresh(async () => {
52
- * await refreshAccessToken();
53
- * });
54
- * ```
55
- */
56
- export class AudioBufferQueue {
57
- private queue: AudioChunk[] = [];
58
- private isRefreshing = false;
59
- private isSending = false;
60
- private readonly options: Required<AudioBufferQueueOptions>;
61
-
62
- constructor(options: AudioBufferQueueOptions = {}) {
63
- this.options = {
64
- maxQueueSize: options.maxQueueSize ?? 100,
65
- maxRetries: options.maxRetries ?? 3,
66
- retryDelay: options.retryDelay ?? 1000,
67
- };
68
- }
69
-
70
- /**
71
- * 发送音频数据
72
- *
73
- * @param sessionId - Session ID
74
- * @param audioData - Base64 encoded audio data
75
- * @param sequence - Sequence number
76
- * @param sendFn - 发送函数
77
- * @returns Promise
78
- */
79
- async send(
80
- sessionId: string,
81
- audioData: string,
82
- sequence: number,
83
- sendFn: (
84
- sessionId: string,
85
- audioData: string,
86
- sequence: number,
87
- ) => Promise<void>,
88
- ): Promise<void> {
89
- const chunk: AudioChunk = {
90
- audioData,
91
- sequence,
92
- timestamp: Date.now(),
93
- };
94
-
95
- // 如果正在 refresh 或队列中有数据,先缓冲
96
- if (this.isRefreshing || this.queue.length > 0) {
97
- this.enqueue(chunk);
98
- return;
99
- }
100
-
101
- // 尝试直接发送
102
- try {
103
- await sendFn(sessionId, audioData, sequence);
104
- } catch (error) {
105
- // 检查是否是认证错误
106
- if (this.isAuthError(error)) {
107
- // 认证错误,缓冲当前数据并触发 refresh
108
- this.enqueue(chunk);
109
- throw error; // 抛出错误,让调用方知道需要 refresh
110
- } else {
111
- // 其他错误,直接抛出
112
- throw error;
113
- }
114
- }
115
- }
116
-
117
- /**
118
- * 处理 token refresh
119
- *
120
- * @param refreshFn - Token refresh 函数
121
- */
122
- async handleTokenRefresh(refreshFn: () => Promise<void>): Promise<void> {
123
- if (this.isRefreshing) {
124
- console.warn('[AudioBufferQueue] Already refreshing, skipping');
125
- return;
126
- }
127
-
128
- this.isRefreshing = true;
129
-
130
- try {
131
- // 执行 token refresh
132
- await refreshFn();
133
-
134
- // Refresh 成功,flush 队列
135
- await this.flush(async (_chunk) => {
136
- // 这里需要调用方传入 sendFn
137
- // 所以我们需要调整设计
138
- });
139
- } catch (error) {
140
- console.error('[AudioBufferQueue] Token refresh failed:', error);
141
- throw error;
142
- } finally {
143
- this.isRefreshing = false;
144
- }
145
- }
146
-
147
- /**
148
- * 将音频数据加入缓冲队列
149
- */
150
- private enqueue(chunk: AudioChunk): void {
151
- if (this.queue.length >= this.options.maxQueueSize) {
152
- console.warn('[AudioBufferQueue] Queue is full, dropping oldest chunk', {
153
- queueSize: this.queue.length,
154
- droppedChunk: this.queue[0],
155
- });
156
- this.queue.shift(); // 移除最旧的数据
157
- }
158
-
159
- this.queue.push(chunk);
160
- console.log('[AudioBufferQueue] Chunk enqueued', {
161
- sequence: chunk.sequence,
162
- queueSize: this.queue.length,
163
- });
164
- }
165
-
166
- /**
167
- * Flush 队列:按序发送所有缓冲的音频数据
168
- */
169
- async flush(
170
- sendFn: (
171
- sessionId: string,
172
- audioData: string,
173
- sequence: number,
174
- ) => Promise<void>,
175
- sessionId: string,
176
- ): Promise<void> {
177
- if (this.isSending) {
178
- console.warn('[AudioBufferQueue] Already flushing, skipping');
179
- return;
180
- }
181
-
182
- if (this.queue.length === 0) {
183
- return;
184
- }
185
-
186
- this.isSending = true;
187
- console.log('[AudioBufferQueue] Flushing queue', {
188
- queueSize: this.queue.length,
189
- });
190
-
191
- const chunksToSend = [...this.queue];
192
- this.queue = []; // 清空队列
193
-
194
- for (const chunk of chunksToSend) {
195
- let retries = 0;
196
- while (retries < this.options.maxRetries) {
197
- try {
198
- await sendFn(sessionId, chunk.audioData, chunk.sequence);
199
- break; // 发送成功,跳出重试循环
200
- } catch (error) {
201
- retries++;
202
- console.error(
203
- `[AudioBufferQueue] Failed to send chunk (attempt ${retries}/${this.options.maxRetries})`,
204
- {
205
- sequence: chunk.sequence,
206
- error,
207
- },
208
- );
209
-
210
- if (retries >= this.options.maxRetries) {
211
- console.error(
212
- '[AudioBufferQueue] Max retries reached, dropping chunk',
213
- {
214
- sequence: chunk.sequence,
215
- },
216
- );
217
- // TODO: 通知用户数据丢失
218
- break;
219
- }
220
-
221
- // 等待重试延迟
222
- await this.sleep(this.options.retryDelay);
223
- }
224
- }
225
- }
226
-
227
- this.isSending = false;
228
- console.log('[AudioBufferQueue] Queue flushed');
229
- }
230
-
231
- /**
232
- * 检查是否是认证错误
233
- */
234
- private isAuthError(error: unknown): boolean {
235
- if (error && typeof error === 'object') {
236
- const err = error as unknown;
237
- return (
238
- err.status === 401 ||
239
- err.statusCode === 401 ||
240
- err.code === 'UNAUTHORIZED' ||
241
- err.message?.includes('Unauthorized') ||
242
- err.message?.includes('token')
243
- );
244
- }
245
- return false;
246
- }
247
-
248
- /**
249
- * 延迟函数
250
- */
251
- private sleep(ms: number): Promise<void> {
252
- return new Promise((resolve) => setTimeout(resolve, ms));
253
- }
254
-
255
- /**
256
- * 获取队列状态
257
- */
258
- getStatus() {
259
- return {
260
- queueSize: this.queue.length,
261
- isRefreshing: this.isRefreshing,
262
- isSending: this.isSending,
263
- };
264
- }
265
-
266
- /**
267
- * 清空队列
268
- */
269
- clear(): void {
270
- this.queue = [];
271
- console.log('[AudioBufferQueue] Queue cleared');
272
- }
273
- }
@@ -1,295 +0,0 @@
1
- /**
2
- * 文件夹上传工具函数
3
- * 支持解析 webkitdirectory 和拖拽上传的文件夹结构
4
- */
5
-
6
- /**
7
- * 文件夹结构
8
- */
9
- export interface FolderStruct {
10
- name: string;
11
- path: string;
12
- children?: FolderStruct[];
13
- }
14
-
15
- /**
16
- * 文件及其相对路径信息
17
- */
18
- export interface FileWithPath {
19
- file: File;
20
- /** 相对路径(不含文件名) */
21
- relativePath: string;
22
- /** 完整相对路径(含文件名) */
23
- fullPath: string;
24
- }
25
-
26
- /**
27
- * 解析 webkitdirectory 选择的文件列表
28
- * 从 webkitRelativePath 提取文件夹结构
29
- *
30
- * @param files 文件列表
31
- * @returns 文件夹结构和文件列表
32
- */
33
- export function parseWebkitDirectory(files: File[]): {
34
- folderStruct: FolderStruct | null;
35
- filesWithPath: FileWithPath[];
36
- } {
37
- if (files.length === 0) {
38
- return { folderStruct: null, filesWithPath: [] };
39
- }
40
-
41
- const filesWithPath: FileWithPath[] = [];
42
- const folderPaths = new Set<string>();
43
-
44
- // 收集所有文件夹路径
45
- for (const file of files) {
46
- // webkitRelativePath 格式: "folder/subfolder/file.txt"
47
- const relativePath = (file as File & { webkitRelativePath?: string })
48
- .webkitRelativePath;
49
- if (!relativePath) continue;
50
-
51
- const pathParts = relativePath.split('/');
52
- pathParts.pop(); // Remove file name from path
53
- const dirPath = pathParts.join('/');
54
-
55
- filesWithPath.push({
56
- file,
57
- relativePath: dirPath,
58
- fullPath: relativePath,
59
- });
60
-
61
- // 收集所有父级文件夹路径
62
- let currentPath = '';
63
- for (const part of pathParts) {
64
- currentPath = currentPath ? `${currentPath}/${part}` : part;
65
- folderPaths.add(currentPath);
66
- }
67
- }
68
-
69
- // 构建文件夹结构树
70
- const folderStruct = buildFolderStruct(Array.from(folderPaths));
71
-
72
- return { folderStruct, filesWithPath };
73
- }
74
-
75
- /**
76
- * 从路径列表构建文件夹结构
77
- */
78
- function buildFolderStruct(paths: string[]): FolderStruct | null {
79
- if (paths.length === 0) return null;
80
-
81
- // 按深度排序
82
- const sortedPaths = paths.sort((a, b) => {
83
- const depthA = a.split('/').length;
84
- const depthB = b.split('/').length;
85
- return depthA - depthB;
86
- });
87
-
88
- // 获取根文件夹名称
89
- const rootPath = sortedPaths[0] || '';
90
- const rootName = rootPath.split('/')[0] || '';
91
-
92
- // 构建树结构
93
- const root: FolderStruct = { name: rootName };
94
- const nodeMap = new Map<string, FolderStruct>();
95
- nodeMap.set(rootName, root);
96
-
97
- for (const path of sortedPaths) {
98
- const parts = path.split('/');
99
- if (parts.length === 1) continue; // 跳过根节点
100
-
101
- let currentPath = parts[0];
102
- let parentNode = nodeMap.get(currentPath || '');
103
- if (!parentNode) continue; // Skip if root node not found
104
-
105
- for (let i = 1; i < parts.length; i++) {
106
- currentPath = `${currentPath}/${parts[i]}`;
107
-
108
- if (!nodeMap.has(currentPath)) {
109
- const newNode: FolderStruct = { name: parts[i] || '' };
110
- if (!parentNode.children) {
111
- parentNode.children = [];
112
- }
113
- parentNode.children.push(newNode);
114
- nodeMap.set(currentPath, newNode);
115
- }
116
-
117
- const nextNode = nodeMap.get(currentPath);
118
- if (!nextNode) break; // Skip if node not found
119
- parentNode = nextNode;
120
- }
121
- }
122
-
123
- return root;
124
- }
125
-
126
- /**
127
- * 从 DataTransferItemList 解析拖拽的文件和文件夹
128
- * 使用 webkitGetAsEntry API 递归读取文件夹内容
129
- */
130
- export async function parseDragDropItems(items: DataTransferItemList): Promise<{
131
- folderStruct: FolderStruct | null;
132
- filesWithPath: FileWithPath[];
133
- }> {
134
- const filesWithPath: FileWithPath[] = [];
135
- const folderPaths = new Set<string>();
136
- const rootFolders: FolderStruct[] = [];
137
-
138
- const entries = Array.from(items)
139
- .map((item) => item.webkitGetAsEntry?.())
140
- .filter((entry): entry is FileSystemEntry => entry !== null);
141
-
142
- for (const entry of entries) {
143
- if (entry.isDirectory) {
144
- const dirEntry = entry as FileSystemDirectoryEntry;
145
- const folderStruct = await readDirectoryEntry(
146
- dirEntry,
147
- '',
148
- filesWithPath,
149
- folderPaths,
150
- );
151
- if (folderStruct) {
152
- rootFolders.push(folderStruct);
153
- }
154
- } else if (entry.isFile) {
155
- const fileEntry = entry as FileSystemFileEntry;
156
- const file = await getFileFromEntry(fileEntry);
157
- if (file) {
158
- filesWithPath.push({
159
- file,
160
- relativePath: '',
161
- fullPath: file.name,
162
- });
163
- }
164
- }
165
- }
166
-
167
- // 如果只有一个根文件夹,直接返回
168
- // 如果有多个,创建一个虚拟根节点
169
- let folderStruct: FolderStruct | null = null;
170
- if (rootFolders.length === 1) {
171
- folderStruct = rootFolders[0] || null;
172
- } else if (rootFolders.length > 1) {
173
- // 多个根文件夹不需要虚拟根节点,分别处理
174
- folderStruct = rootFolders[0] || null; // 暂时只处理第一个
175
- }
176
-
177
- return { folderStruct, filesWithPath };
178
- }
179
-
180
- /**
181
- * 递归读取目录条目
182
- */
183
- async function readDirectoryEntry(
184
- dirEntry: FileSystemDirectoryEntry,
185
- parentPath: string,
186
- filesWithPath: FileWithPath[],
187
- folderPaths: Set<string>,
188
- ): Promise<FolderStruct> {
189
- const currentPath = parentPath
190
- ? `${parentPath}/${dirEntry.name}`
191
- : dirEntry.name;
192
- folderPaths.add(currentPath);
193
-
194
- const folderStruct: FolderStruct = { name: dirEntry.name };
195
- const children: FolderStruct[] = [];
196
-
197
- const entries = await readAllDirectoryEntries(dirEntry);
198
-
199
- for (const entry of entries) {
200
- if (entry.isDirectory) {
201
- const childStruct = await readDirectoryEntry(
202
- entry as FileSystemDirectoryEntry,
203
- currentPath,
204
- filesWithPath,
205
- folderPaths,
206
- );
207
- children.push(childStruct);
208
- } else if (entry.isFile) {
209
- const file = await getFileFromEntry(entry as FileSystemFileEntry);
210
- if (file) {
211
- filesWithPath.push({
212
- file,
213
- relativePath: currentPath,
214
- fullPath: `${currentPath}/${file.name}`,
215
- });
216
- }
217
- }
218
- }
219
-
220
- if (children.length > 0) {
221
- folderStruct.children = children;
222
- }
223
-
224
- return folderStruct;
225
- }
226
-
227
- /**
228
- * 读取目录中的所有条目
229
- */
230
- function readAllDirectoryEntries(
231
- dirEntry: FileSystemDirectoryEntry,
232
- ): Promise<FileSystemEntry[]> {
233
- return new Promise((resolve, reject) => {
234
- const reader = dirEntry.createReader();
235
- const entries: FileSystemEntry[] = [];
236
-
237
- const readEntries = () => {
238
- reader.readEntries(
239
- (results) => {
240
- if (results.length === 0) {
241
- resolve(entries);
242
- } else {
243
- entries.push(...results);
244
- readEntries(); // 继续读取(API 可能分批返回)
245
- }
246
- },
247
- (error) => reject(error),
248
- );
249
- };
250
-
251
- readEntries();
252
- });
253
- }
254
-
255
- /**
256
- * 从 FileSystemFileEntry 获取 File 对象
257
- */
258
- function getFileFromEntry(
259
- fileEntry: FileSystemFileEntry,
260
- ): Promise<File | null> {
261
- return new Promise((resolve) => {
262
- fileEntry.file(
263
- (file) => resolve(file),
264
- () => resolve(null),
265
- );
266
- });
267
- }
268
-
269
- /**
270
- * 根据文件夹结构和创建的文件夹 ID 映射,获取文件应该上传到的文件夹 ID
271
- */
272
- export function getTargetFolderId(
273
- relativePath: string,
274
- folderIdMap: Map<string, string>,
275
- defaultFolderId: string,
276
- ): string {
277
- if (!relativePath) {
278
- return defaultFolderId;
279
- }
280
-
281
- // 尝试找到最近的父文件夹 ID
282
- const pathParts = relativePath.split('/');
283
- let currentPath = '';
284
- let lastFoundId = defaultFolderId;
285
-
286
- for (const part of pathParts) {
287
- currentPath = currentPath ? `${currentPath}/${part}` : part;
288
- const folderId = folderIdMap.get(currentPath);
289
- if (folderId) {
290
- lastFoundId = folderId;
291
- }
292
- }
293
-
294
- return lastFoundId;
295
- }