Browse Source

feat(导出记录): 增强下载和删除功能,添加导出记录 ID 编码和验证

Zhang Yice 1 month ago
parent
commit
f319fa4be2

+ 33 - 4
src/components/ExportRecordList/ExportRecordList.tsx

@@ -77,6 +77,8 @@ const ExportRecordList: React.FC<ExportRecordListProps> = ({
77
   const [loading, setLoading] = useState(true);
77
   const [loading, setLoading] = useState(true);
78
   const [page, setPage] = useState(1);
78
   const [page, setPage] = useState(1);
79
   const [total, setTotal] = useState(0);
79
   const [total, setTotal] = useState(0);
80
+  const [downloadingIds, setDownloadingIds] = useState<Set<string>>(new Set());
81
+  const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
80
   const pageSize = 20;
82
   const pageSize = 20;
81
 
83
 
82
   /**
84
   /**
@@ -132,13 +134,21 @@ const ExportRecordList: React.FC<ExportRecordListProps> = ({
132
   const handleDownload = useCallback(
134
   const handleDownload = useCallback(
133
     async (record: ExportRecord) => {
135
     async (record: ExportRecord) => {
134
       try {
136
       try {
137
+        if (downloadingIds.has(record.recordId)) return;
138
+        setDownloadingIds((current) => new Set(current).add(record.recordId));
135
         await downloadExportRecord(record.recordId, userId);
139
         await downloadExportRecord(record.recordId, userId);
136
         message.success('开始下载文档');
140
         message.success('开始下载文档');
137
       } catch {
141
       } catch {
138
         message.error('下载失败,请重试');
142
         message.error('下载失败,请重试');
143
+      } finally {
144
+        setDownloadingIds((current) => {
145
+          const next = new Set(current);
146
+          next.delete(record.recordId);
147
+          return next;
148
+        });
139
       }
149
       }
140
     },
150
     },
141
-    [userId]
151
+    [downloadingIds, userId]
142
   );
152
   );
143
 
153
 
144
   /**
154
   /**
@@ -147,22 +157,35 @@ const ExportRecordList: React.FC<ExportRecordListProps> = ({
147
   const handleDelete = useCallback(
157
   const handleDelete = useCallback(
148
     async (record: ExportRecord) => {
158
     async (record: ExportRecord) => {
149
       try {
159
       try {
160
+        if (deletingIds.has(record.recordId)) return;
161
+        setDeletingIds((current) => new Set(current).add(record.recordId));
150
         await deleteExportRecord(record.recordId, userId);
162
         await deleteExportRecord(record.recordId, userId);
151
         message.success('删除成功');
163
         message.success('删除成功');
152
         // Refresh the list
164
         // Refresh the list
153
         setLoading(true);
165
         setLoading(true);
154
-        await fetchRecords();
166
+        if (page > 1 && records.length === 1) {
167
+          setPage((currentPage) => currentPage - 1);
168
+        } else {
169
+          await fetchRecords();
170
+        }
155
       } catch {
171
       } catch {
156
         message.error('删除失败,请重试');
172
         message.error('删除失败,请重试');
173
+      } finally {
174
+        setDeletingIds((current) => {
175
+          const next = new Set(current);
176
+          next.delete(record.recordId);
177
+          return next;
178
+        });
157
       }
179
       }
158
     },
180
     },
159
-    [userId, fetchRecords]
181
+    [deletingIds, fetchRecords, page, records.length, userId]
160
   );
182
   );
161
 
183
 
162
   /**
184
   /**
163
    * Format file size to human-readable string
185
    * Format file size to human-readable string
164
    */
186
    */
165
   const formatFileSize = (bytes: number): string => {
187
   const formatFileSize = (bytes: number): string => {
188
+    if (!Number.isFinite(bytes) || bytes < 0) return '大小未知';
166
     if (bytes < 1024) return `${bytes} B`;
189
     if (bytes < 1024) return `${bytes} B`;
167
     if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
190
     if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
168
     return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
191
     return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
@@ -173,6 +196,7 @@ const ExportRecordList: React.FC<ExportRecordListProps> = ({
173
    */
196
    */
174
   const formatDate = (timestamp: number): string => {
197
   const formatDate = (timestamp: number): string => {
175
     const date = new Date(timestamp);
198
     const date = new Date(timestamp);
199
+    if (Number.isNaN(date.getTime())) return '时间未知';
176
     return date.toLocaleString('zh-CN', {
200
     return date.toLocaleString('zh-CN', {
177
       year: 'numeric',
201
       year: 'numeric',
178
       month: '2-digit',
202
       month: '2-digit',
@@ -224,6 +248,8 @@ const ExportRecordList: React.FC<ExportRecordListProps> = ({
224
                         type="text"
248
                         type="text"
225
                         icon={<DownloadOutlined />}
249
                         icon={<DownloadOutlined />}
226
                         onClick={() => handleDownload(record)}
250
                         onClick={() => handleDownload(record)}
251
+                        loading={downloadingIds.has(record.recordId)}
252
+                        disabled={deletingIds.has(record.recordId)}
227
                         data-testid="download-button"
253
                         data-testid="download-button"
228
                       />
254
                       />
229
                     </Tooltip>,
255
                     </Tooltip>,
@@ -233,7 +259,10 @@ const ExportRecordList: React.FC<ExportRecordListProps> = ({
233
                       onConfirm={() => handleDelete(record)}
259
                       onConfirm={() => handleDelete(record)}
234
                       okText="删除"
260
                       okText="删除"
235
                       cancelText="取消"
261
                       cancelText="取消"
236
-                      okButtonProps={{ danger: true }}
262
+                      okButtonProps={{
263
+                        danger: true,
264
+                        loading: deletingIds.has(record.recordId),
265
+                      }}
237
                     >
266
                     >
238
                       <Tooltip title="删除">
267
                       <Tooltip title="删除">
239
                         <Button
268
                         <Button

+ 38 - 3
src/services/exportRecordService.ts

@@ -15,11 +15,34 @@
15
 import apiClient, { getErrorMessage } from './api';
15
 import apiClient, { getErrorMessage } from './api';
16
 import type { ApiResponse } from '../types/api';
16
 import type { ApiResponse } from '../types/api';
17
 import type {
17
 import type {
18
+  ExportRecord,
18
   ListExportRecordsResponse,
19
   ListExportRecordsResponse,
19
   ExportRecordListFilters,
20
   ExportRecordListFilters,
20
   StorageInfo,
21
   StorageInfo,
21
 } from '../types/export';
22
 } from '../types/export';
22
-import { downloadBlob, getFileNameFromContentDisposition } from '../utils/download';
23
+import {
24
+  downloadBlob,
25
+  encodeExportRecordId,
26
+  getFileNameFromContentDisposition,
27
+} from '../utils/download';
28
+
29
+function isValidExportRecord(value: unknown): value is ExportRecord {
30
+  if (!value || typeof value !== 'object') return false;
31
+  const record = value as Partial<ExportRecord>;
32
+  return (
33
+    typeof record.recordId === 'string' &&
34
+    record.recordId.length > 0 &&
35
+    typeof record.fileName === 'string' &&
36
+    typeof record.downloadUrl === 'string' &&
37
+    typeof record.userId === 'string' &&
38
+    typeof record.styleId === 'string' &&
39
+    typeof record.fileSize === 'number' &&
40
+    Number.isFinite(record.fileSize) &&
41
+    record.fileSize >= 0 &&
42
+    typeof record.createdAt === 'number' &&
43
+    Number.isFinite(record.createdAt)
44
+  );
45
+}
23
 
46
 
24
 /**
47
 /**
25
  * List export records with pagination
48
  * List export records with pagination
@@ -46,6 +69,16 @@ export const listExportRecords = async (
46
     );
69
     );
47
 
70
 
48
     const data = response.data.data;
71
     const data = response.data.data;
72
+    if (
73
+      !data ||
74
+      !Array.isArray(data.records) ||
75
+      !data.records.every(isValidExportRecord) ||
76
+      !data.pagination ||
77
+      !Number.isInteger(data.pagination.total) ||
78
+      data.pagination.total < 0
79
+    ) {
80
+      throw new Error('导出记录响应格式无效');
81
+    }
49
 
82
 
50
     // Fix any incorrect base URLs in download URLs
83
     // Fix any incorrect base URLs in download URLs
51
     const correctBaseURL = import.meta.env.VITE_API_BASE_URL;
84
     const correctBaseURL = import.meta.env.VITE_API_BASE_URL;
@@ -88,9 +121,10 @@ export const listExportRecords = async (
88
  */
121
  */
89
 export const downloadExportRecord = async (recordId: string, userId: string): Promise<void> => {
122
 export const downloadExportRecord = async (recordId: string, userId: string): Promise<void> => {
90
   try {
123
   try {
124
+    const encodedRecordId = encodeExportRecordId(recordId);
91
     // Use apiClient to download the file (goes through axios with proper config)
125
     // Use apiClient to download the file (goes through axios with proper config)
92
     // This avoids mixed content warnings by using relative URLs
126
     // This avoids mixed content warnings by using relative URLs
93
-    const response = await apiClient.get(`/api/v1/export/records/${recordId}/download`, {
127
+    const response = await apiClient.get(`/api/v1/export/records/${encodedRecordId}/download`, {
94
       params: { userId },
128
       params: { userId },
95
       responseType: 'blob', // Important: tell axios to expect binary data
129
       responseType: 'blob', // Important: tell axios to expect binary data
96
     });
130
     });
@@ -122,7 +156,8 @@ export const downloadExportRecord = async (recordId: string, userId: string): Pr
122
  */
156
  */
123
 export const deleteExportRecord = async (recordId: string, userId: string): Promise<void> => {
157
 export const deleteExportRecord = async (recordId: string, userId: string): Promise<void> => {
124
   try {
158
   try {
125
-    await apiClient.delete<ApiResponse<void>>(`/api/v1/export/records/${recordId}`, {
159
+    const encodedRecordId = encodeExportRecordId(recordId);
160
+    await apiClient.delete<ApiResponse<void>>(`/api/v1/export/records/${encodedRecordId}`, {
126
       params: { userId },
161
       params: { userId },
127
     });
162
     });
128
   } catch (error) {
163
   } catch (error) {

+ 8 - 0
src/utils/download.ts

@@ -1,4 +1,5 @@
1
 const UNSAFE_FILE_NAME_CHARS = /[\\/:*?"<>|]/g;
1
 const UNSAFE_FILE_NAME_CHARS = /[\\/:*?"<>|]/g;
2
+const EXPORT_RECORD_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
2
 
3
 
3
 export function safeFileName(fileName: string, fallback = 'download'): string {
4
 export function safeFileName(fileName: string, fallback = 'download'): string {
4
   const normalized = Array.from(fileName, (character) =>
5
   const normalized = Array.from(fileName, (character) =>
@@ -12,6 +13,13 @@ export function safeFileName(fileName: string, fallback = 'download'): string {
12
   return normalized || fallback;
13
   return normalized || fallback;
13
 }
14
 }
14
 
15
 
16
+export function encodeExportRecordId(recordId: string): string {
17
+  if (!EXPORT_RECORD_ID_PATTERN.test(recordId)) {
18
+    throw new Error('无效的导出记录 ID');
19
+  }
20
+  return encodeURIComponent(recordId);
21
+}
22
+
15
 export function getFileNameFromContentDisposition(
23
 export function getFileNameFromContentDisposition(
16
   header: string | null | undefined,
24
   header: string | null | undefined,
17
   fallback: string
25
   fallback: string

+ 2 - 0
vite.config.ts

@@ -72,6 +72,8 @@ export default defineConfig({
72
       'zustand',
72
       'zustand',
73
       'antd',
73
       'antd',
74
       '@ant-design/icons',
74
       '@ant-design/icons',
75
+      'html2canvas',
76
+      'jspdf',
75
     ],
77
     ],
76
     // 排除不需要预构建的大型依赖
78
     // 排除不需要预构建的大型依赖
77
     exclude: [
79
     exclude: [