Przeglądaj źródła

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

Zhang Yice 1 miesiąc temu
rodzic
commit
f319fa4be2

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

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

+ 38 - 3
src/services/exportRecordService.ts

@@ -15,11 +15,34 @@
15 15
 import apiClient, { getErrorMessage } from './api';
16 16
 import type { ApiResponse } from '../types/api';
17 17
 import type {
18
+  ExportRecord,
18 19
   ListExportRecordsResponse,
19 20
   ExportRecordListFilters,
20 21
   StorageInfo,
21 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 48
  * List export records with pagination
@@ -46,6 +69,16 @@ export const listExportRecords = async (
46 69
     );
47 70
 
48 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 83
     // Fix any incorrect base URLs in download URLs
51 84
     const correctBaseURL = import.meta.env.VITE_API_BASE_URL;
@@ -88,9 +121,10 @@ export const listExportRecords = async (
88 121
  */
89 122
 export const downloadExportRecord = async (recordId: string, userId: string): Promise<void> => {
90 123
   try {
124
+    const encodedRecordId = encodeExportRecordId(recordId);
91 125
     // Use apiClient to download the file (goes through axios with proper config)
92 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 128
       params: { userId },
95 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 157
 export const deleteExportRecord = async (recordId: string, userId: string): Promise<void> => {
124 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 161
       params: { userId },
127 162
     });
128 163
   } catch (error) {

+ 8 - 0
src/utils/download.ts

@@ -1,4 +1,5 @@
1 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 4
 export function safeFileName(fileName: string, fallback = 'download'): string {
4 5
   const normalized = Array.from(fileName, (character) =>
@@ -12,6 +13,13 @@ export function safeFileName(fileName: string, fallback = 'download'): string {
12 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 23
 export function getFileNameFromContentDisposition(
16 24
   header: string | null | undefined,
17 25
   fallback: string

+ 2 - 0
vite.config.ts

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