@spacecorexdev/core 0.0.2 → 0.0.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 +20 -38
- package/public/cmaps/78-EUC-H.bcmap +0 -0
- package/public/cmaps/78-EUC-V.bcmap +0 -0
- package/public/cmaps/78-H.bcmap +0 -0
- package/public/cmaps/78-RKSJ-H.bcmap +0 -0
- package/public/cmaps/78-RKSJ-V.bcmap +0 -0
- package/public/cmaps/78-V.bcmap +0 -0
- package/public/cmaps/78ms-RKSJ-H.bcmap +0 -0
- package/public/cmaps/78ms-RKSJ-V.bcmap +0 -0
- package/public/cmaps/83pv-RKSJ-H.bcmap +0 -0
- package/public/cmaps/90ms-RKSJ-H.bcmap +0 -0
- package/public/cmaps/90ms-RKSJ-V.bcmap +0 -0
- package/public/cmaps/90msp-RKSJ-H.bcmap +0 -0
- package/public/cmaps/90msp-RKSJ-V.bcmap +0 -0
- package/public/cmaps/90pv-RKSJ-H.bcmap +0 -0
- package/public/cmaps/90pv-RKSJ-V.bcmap +0 -0
- package/public/cmaps/Add-H.bcmap +0 -0
- package/public/cmaps/Add-RKSJ-H.bcmap +0 -0
- package/public/cmaps/Add-RKSJ-V.bcmap +0 -0
- package/public/cmaps/Add-V.bcmap +0 -0
- package/public/cmaps/Adobe-CNS1-0.bcmap +0 -0
- package/public/cmaps/Adobe-CNS1-1.bcmap +0 -0
- package/public/cmaps/Adobe-CNS1-2.bcmap +0 -0
- package/public/cmaps/Adobe-CNS1-3.bcmap +0 -0
- package/public/cmaps/Adobe-CNS1-4.bcmap +0 -0
- package/public/cmaps/Adobe-CNS1-5.bcmap +0 -0
- package/public/cmaps/Adobe-CNS1-6.bcmap +0 -0
- package/public/cmaps/Adobe-CNS1-UCS2.bcmap +0 -0
- package/public/cmaps/Adobe-GB1-0.bcmap +0 -0
- package/public/cmaps/Adobe-GB1-1.bcmap +0 -0
- package/public/cmaps/Adobe-GB1-2.bcmap +0 -0
- package/public/cmaps/Adobe-GB1-3.bcmap +0 -0
- package/public/cmaps/Adobe-GB1-4.bcmap +0 -0
- package/public/cmaps/Adobe-GB1-5.bcmap +0 -0
- package/public/cmaps/Adobe-GB1-UCS2.bcmap +0 -0
- package/public/cmaps/Adobe-Japan1-0.bcmap +0 -0
- package/public/cmaps/Adobe-Japan1-1.bcmap +0 -0
- package/public/cmaps/Adobe-Japan1-2.bcmap +0 -0
- package/public/cmaps/Adobe-Japan1-3.bcmap +0 -0
- package/public/cmaps/Adobe-Japan1-4.bcmap +0 -0
- package/public/cmaps/Adobe-Japan1-5.bcmap +0 -0
- package/public/cmaps/Adobe-Japan1-6.bcmap +0 -0
- package/public/cmaps/Adobe-Japan1-UCS2.bcmap +0 -0
- package/public/cmaps/Adobe-Korea1-0.bcmap +0 -0
- package/public/cmaps/Adobe-Korea1-1.bcmap +0 -0
- package/public/cmaps/Adobe-Korea1-2.bcmap +0 -0
- package/public/cmaps/Adobe-Korea1-UCS2.bcmap +0 -0
- package/public/cmaps/B5-H.bcmap +0 -0
- package/public/cmaps/B5-V.bcmap +0 -0
- package/public/cmaps/B5pc-H.bcmap +0 -0
- package/public/cmaps/B5pc-V.bcmap +0 -0
- package/public/cmaps/CNS-EUC-H.bcmap +0 -0
- package/public/cmaps/CNS-EUC-V.bcmap +0 -0
- package/public/cmaps/CNS1-H.bcmap +0 -0
- package/public/cmaps/CNS1-V.bcmap +0 -0
- package/public/cmaps/CNS2-H.bcmap +0 -0
- package/public/cmaps/CNS2-V.bcmap +3 -0
- package/public/cmaps/ETHK-B5-H.bcmap +0 -0
- package/public/cmaps/ETHK-B5-V.bcmap +0 -0
- package/public/cmaps/ETen-B5-H.bcmap +0 -0
- package/public/cmaps/ETen-B5-V.bcmap +0 -0
- package/public/cmaps/ETenms-B5-H.bcmap +3 -0
- package/public/cmaps/ETenms-B5-V.bcmap +0 -0
- package/public/cmaps/EUC-H.bcmap +0 -0
- package/public/cmaps/EUC-V.bcmap +0 -0
- package/public/cmaps/Ext-H.bcmap +0 -0
- package/public/cmaps/Ext-RKSJ-H.bcmap +0 -0
- package/public/cmaps/Ext-RKSJ-V.bcmap +0 -0
- package/public/cmaps/Ext-V.bcmap +0 -0
- package/public/cmaps/GB-EUC-H.bcmap +0 -0
- package/public/cmaps/GB-EUC-V.bcmap +0 -0
- package/public/cmaps/GB-H.bcmap +4 -0
- package/public/cmaps/GB-V.bcmap +0 -0
- package/public/cmaps/GBK-EUC-H.bcmap +0 -0
- package/public/cmaps/GBK-EUC-V.bcmap +0 -0
- package/public/cmaps/GBK2K-H.bcmap +0 -0
- package/public/cmaps/GBK2K-V.bcmap +0 -0
- package/public/cmaps/GBKp-EUC-H.bcmap +0 -0
- package/public/cmaps/GBKp-EUC-V.bcmap +0 -0
- package/public/cmaps/GBT-EUC-H.bcmap +0 -0
- package/public/cmaps/GBT-EUC-V.bcmap +0 -0
- package/public/cmaps/GBT-H.bcmap +0 -0
- package/public/cmaps/GBT-V.bcmap +0 -0
- package/public/cmaps/GBTpc-EUC-H.bcmap +0 -0
- package/public/cmaps/GBTpc-EUC-V.bcmap +0 -0
- package/public/cmaps/GBpc-EUC-H.bcmap +0 -0
- package/public/cmaps/GBpc-EUC-V.bcmap +0 -0
- package/public/cmaps/H.bcmap +0 -0
- package/public/cmaps/HKdla-B5-H.bcmap +0 -0
- package/public/cmaps/HKdla-B5-V.bcmap +0 -0
- package/public/cmaps/HKdlb-B5-H.bcmap +0 -0
- package/public/cmaps/HKdlb-B5-V.bcmap +0 -0
- package/public/cmaps/HKgccs-B5-H.bcmap +0 -0
- package/public/cmaps/HKgccs-B5-V.bcmap +0 -0
- package/public/cmaps/HKm314-B5-H.bcmap +0 -0
- package/public/cmaps/HKm314-B5-V.bcmap +0 -0
- package/public/cmaps/HKm471-B5-H.bcmap +0 -0
- package/public/cmaps/HKm471-B5-V.bcmap +0 -0
- package/public/cmaps/HKscs-B5-H.bcmap +0 -0
- package/public/cmaps/HKscs-B5-V.bcmap +0 -0
- package/public/cmaps/Hankaku.bcmap +0 -0
- package/public/cmaps/Hiragana.bcmap +0 -0
- package/public/cmaps/KSC-EUC-H.bcmap +0 -0
- package/public/cmaps/KSC-EUC-V.bcmap +0 -0
- package/public/cmaps/KSC-H.bcmap +0 -0
- package/public/cmaps/KSC-Johab-H.bcmap +0 -0
- package/public/cmaps/KSC-Johab-V.bcmap +0 -0
- package/public/cmaps/KSC-V.bcmap +0 -0
- package/public/cmaps/KSCms-UHC-H.bcmap +0 -0
- package/public/cmaps/KSCms-UHC-HW-H.bcmap +0 -0
- package/public/cmaps/KSCms-UHC-HW-V.bcmap +0 -0
- package/public/cmaps/KSCms-UHC-V.bcmap +0 -0
- package/public/cmaps/KSCpc-EUC-H.bcmap +0 -0
- package/public/cmaps/KSCpc-EUC-V.bcmap +0 -0
- package/public/cmaps/Katakana.bcmap +0 -0
- package/public/cmaps/LICENSE +36 -0
- package/public/cmaps/NWP-H.bcmap +0 -0
- package/public/cmaps/NWP-V.bcmap +0 -0
- package/public/cmaps/RKSJ-H.bcmap +0 -0
- package/public/cmaps/RKSJ-V.bcmap +0 -0
- package/public/cmaps/Roman.bcmap +0 -0
- package/public/cmaps/UniCNS-UCS2-H.bcmap +0 -0
- package/public/cmaps/UniCNS-UCS2-V.bcmap +0 -0
- package/public/cmaps/UniCNS-UTF16-H.bcmap +0 -0
- package/public/cmaps/UniCNS-UTF16-V.bcmap +0 -0
- package/public/cmaps/UniCNS-UTF32-H.bcmap +0 -0
- package/public/cmaps/UniCNS-UTF32-V.bcmap +0 -0
- package/public/cmaps/UniCNS-UTF8-H.bcmap +0 -0
- package/public/cmaps/UniCNS-UTF8-V.bcmap +0 -0
- package/public/cmaps/UniGB-UCS2-H.bcmap +0 -0
- package/public/cmaps/UniGB-UCS2-V.bcmap +0 -0
- package/public/cmaps/UniGB-UTF16-H.bcmap +0 -0
- package/public/cmaps/UniGB-UTF16-V.bcmap +0 -0
- package/public/cmaps/UniGB-UTF32-H.bcmap +0 -0
- package/public/cmaps/UniGB-UTF32-V.bcmap +0 -0
- package/public/cmaps/UniGB-UTF8-H.bcmap +0 -0
- package/public/cmaps/UniGB-UTF8-V.bcmap +0 -0
- package/public/cmaps/UniJIS-UCS2-H.bcmap +0 -0
- package/public/cmaps/UniJIS-UCS2-HW-H.bcmap +0 -0
- package/public/cmaps/UniJIS-UCS2-HW-V.bcmap +0 -0
- package/public/cmaps/UniJIS-UCS2-V.bcmap +0 -0
- package/public/cmaps/UniJIS-UTF16-H.bcmap +0 -0
- package/public/cmaps/UniJIS-UTF16-V.bcmap +0 -0
- package/public/cmaps/UniJIS-UTF32-H.bcmap +0 -0
- package/public/cmaps/UniJIS-UTF32-V.bcmap +0 -0
- package/public/cmaps/UniJIS-UTF8-H.bcmap +0 -0
- package/public/cmaps/UniJIS-UTF8-V.bcmap +0 -0
- package/public/cmaps/UniJIS2004-UTF16-H.bcmap +0 -0
- package/public/cmaps/UniJIS2004-UTF16-V.bcmap +0 -0
- package/public/cmaps/UniJIS2004-UTF32-H.bcmap +0 -0
- package/public/cmaps/UniJIS2004-UTF32-V.bcmap +0 -0
- package/public/cmaps/UniJIS2004-UTF8-H.bcmap +0 -0
- package/public/cmaps/UniJIS2004-UTF8-V.bcmap +0 -0
- package/public/cmaps/UniJISPro-UCS2-HW-V.bcmap +0 -0
- package/public/cmaps/UniJISPro-UCS2-V.bcmap +0 -0
- package/public/cmaps/UniJISPro-UTF8-V.bcmap +0 -0
- package/public/cmaps/UniJISX0213-UTF32-H.bcmap +0 -0
- package/public/cmaps/UniJISX0213-UTF32-V.bcmap +0 -0
- package/public/cmaps/UniJISX02132004-UTF32-H.bcmap +0 -0
- package/public/cmaps/UniJISX02132004-UTF32-V.bcmap +0 -0
- package/public/cmaps/UniKS-UCS2-H.bcmap +0 -0
- package/public/cmaps/UniKS-UCS2-V.bcmap +0 -0
- package/public/cmaps/UniKS-UTF16-H.bcmap +0 -0
- package/public/cmaps/UniKS-UTF16-V.bcmap +0 -0
- package/public/cmaps/UniKS-UTF32-H.bcmap +0 -0
- package/public/cmaps/UniKS-UTF32-V.bcmap +0 -0
- package/public/cmaps/UniKS-UTF8-H.bcmap +0 -0
- package/public/cmaps/UniKS-UTF8-V.bcmap +0 -0
- package/public/cmaps/V.bcmap +0 -0
- package/public/cmaps/WP-Symbol.bcmap +0 -0
- package/public/polyfills/polyfills.js +802 -0
- package/public/vendor/docx/docx.worker.js +5 -0
- package/public/vendor/docx/jszip.min.js +13 -0
- package/public/vendor/libarchive/libarchive.wasm +0 -0
- package/public/vendor/libarchive/worker-bundle.js +6 -0
- package/public/vendor/pptx/pptx.worker.js +26 -0
- package/public/wasm/cad/0.8.2/dwfv-render.wasm +0 -0
- package/public/wasm/cad/0.8.2/dwg-worker.js +24 -0
- package/public/wasm/cad/0.8.2/dwg-worker.js.map +1 -0
- package/public/wasm/cad/0.8.2/libredwg-web.js +2 -0
- package/public/wasm/cad/0.8.2/libredwg-web.wasm +0 -0
- package/public/wasm/cad/dwfv-render.wasm +0 -0
- package/public/wasm/cad/dwg-worker.js +24 -0
- package/public/wasm/cad/dwg-worker.js.map +1 -0
- package/public/wasm/cad/libredwg-web.js +2 -0
- package/public/wasm/cad/libredwg-web.wasm +0 -0
- package/public/wasm/data/sql-wasm.wasm +0 -0
- package/public/wasm/hdiff.wasm +0 -0
- package/public/wasm/model/LICENSE.occt-import-js.txt +504 -0
- package/public/wasm/model/LICENSE.occt.txt +502 -0
- package/public/wasm/model/occt-import-js.js +19 -0
- package/public/wasm/model/occt-import-js.wasm +0 -0
- package/public/wasm/model/occt-worker.js +83 -0
- package/public/wasm/typst/fonts/DejaVuSansMono-Bold.ttf +0 -0
- package/public/wasm/typst/fonts/DejaVuSansMono-BoldOblique.ttf +0 -0
- package/public/wasm/typst/fonts/DejaVuSansMono-Oblique.ttf +0 -0
- package/public/wasm/typst/fonts/DejaVuSansMono.ttf +0 -0
- package/public/wasm/typst/fonts/LibertinusSerif-Bold.otf +0 -0
- package/public/wasm/typst/fonts/LibertinusSerif-BoldItalic.otf +0 -0
- package/public/wasm/typst/fonts/LibertinusSerif-Italic.otf +0 -0
- package/public/wasm/typst/fonts/LibertinusSerif-Regular.otf +0 -0
- package/public/wasm/typst/fonts/LibertinusSerif-Semibold.otf +0 -0
- package/public/wasm/typst/fonts/LibertinusSerif-SemiboldItalic.otf +0 -0
- package/public/wasm/typst/fonts/NewCM10-Bold.otf +0 -0
- package/public/wasm/typst/fonts/NewCM10-BoldItalic.otf +0 -0
- package/public/wasm/typst/fonts/NewCM10-Italic.otf +0 -0
- package/public/wasm/typst/fonts/NewCM10-Regular.otf +0 -0
- package/public/wasm/typst/fonts/NewCMMath-Bold.otf +0 -0
- package/public/wasm/typst/fonts/NewCMMath-Book.otf +0 -0
- package/public/wasm/typst/fonts/NewCMMath-Regular.otf +0 -0
- package/public/wasm/typst/fonts/README.md +7 -0
- package/public/wasm/typst/typst_ts_renderer_bg.wasm +0 -0
- package/public/wasm/typst/typst_ts_web_compiler_bg.wasm +0 -0
- package/scripts/patch-docx-grid.cjs +412 -0
- package/src/api/_base.ts +243 -0
- package/src/api/index.ts +17 -0
- package/src/api/project.ts +1345 -0
- package/src/api/user.ts +1174 -0
- package/src/contract.ts +50 -0
- package/src/hooks/index.ts +2 -0
- package/{dist/hooks/index.js → src/hooks/useLongPress.ts} +53 -49
- package/src/hooks/useOnlineStatus.ts +22 -0
- package/src/index.ts +14 -0
- package/src/types/id.ts +2 -0
- package/src/types/index.ts +5 -0
- package/src/types/project/base.ts +63 -0
- package/src/types/project/contract.ts +79 -0
- package/src/types/project/contractStatus.tsx +15 -0
- package/src/types/project/index.ts +5 -0
- package/src/types/project/member.ts +46 -0
- package/src/types/project/progress.ts +43 -0
- package/src/types/projectRole.ts +17 -0
- package/src/types/theme.ts +1 -0
- package/src/types/user/index.ts +1 -0
- package/src/types/user/role.ts +7 -0
- package/src/ui/CommentTimeline/index.tsx +330 -0
- package/src/ui/FileManager/FileContextMenu.tsx +628 -0
- package/src/ui/FileManager/FileGridView.tsx +529 -0
- package/src/ui/FileManager/FileTableView.tsx +405 -0
- package/src/ui/FileManager/FileToolbar.tsx +119 -0
- package/src/ui/FileManager/FileVersionHistoryModal.tsx +175 -0
- package/src/ui/FileManager/MoveCopyToProjectModal.tsx +119 -0
- package/src/ui/FileManager/ShareToWechatModal.tsx +419 -0
- package/src/ui/FileManager/hooks/useFileManagerActions.ts +2532 -0
- package/src/ui/FileManager/hooks/useFileManagerState.ts +419 -0
- package/src/ui/FileManager/index.tsx +580 -0
- package/src/ui/FileManager/projectFileManagerApi.ts +57 -0
- package/src/ui/FileManager/types.ts +184 -0
- package/src/ui/FileManager/utils/fileDeltaWorker.ts +193 -0
- package/src/ui/FileManager/utils/fileTree.ts +178 -0
- package/src/ui/FileManager/utils/fileUpload.ts +714 -0
- package/src/ui/FileManager/utils/fileUtils.ts +65 -0
- package/src/ui/InvoiceRecordsList/index.tsx +813 -0
- package/src/ui/MyFileSelectorModal.tsx +261 -0
- package/src/ui/ProjectSelector/index.tsx +472 -0
- package/src/ui/ProjectSelector/projectCache.ts +40 -0
- package/src/ui/RoleSelector/index.tsx +473 -0
- package/src/ui/RoleSelector/roleCache.ts +40 -0
- package/src/ui/UIAlert.tsx +399 -0
- package/src/ui/UIAlertExample.tsx +129 -0
- package/src/ui/UIConfirm.tsx +335 -0
- package/src/ui/UIConfirmExample.tsx +165 -0
- package/src/ui/UIDatePicker.tsx +146 -0
- package/src/ui/UIDateRangePicker.tsx +38 -0
- package/src/ui/UIFilePreview/ExcelEditor/index.tsx +617 -0
- package/src/ui/UIFilePreview/ExcelEditor/types.ts +24 -0
- package/src/ui/UIFilePreview/ExportDropdown.css +85 -0
- package/src/ui/UIFilePreview/ExportDropdown.tsx +109 -0
- package/src/ui/UIFilePreview/IndexedDBUtils.ts +564 -0
- package/src/ui/UIFilePreview/MarkdownEditor/exportMarkdownPdf.ts +576 -0
- package/src/ui/UIFilePreview/MarkdownEditor/index.tsx +677 -0
- package/src/ui/UIFilePreview/MarkdownEditor/markdownEditor.css +1005 -0
- package/src/ui/UIFilePreview/MarkdownEditor/types.ts +15 -0
- package/src/ui/UIFilePreview/PPTXEditor/index.tsx +602 -0
- package/src/ui/UIFilePreview/RichDocEditor/exportRDocMarkdown.ts +77 -0
- package/src/ui/UIFilePreview/RichDocEditor/exportRDocPdf.ts +374 -0
- package/src/ui/UIFilePreview/RichDocEditor/index.tsx +730 -0
- package/src/ui/UIFilePreview/RichDocEditor/richdoc-editor.css +59 -0
- package/src/ui/UIFilePreview/UIFilePreview.tsx +611 -0
- package/src/ui/UIFilePreview/WordEditor/index.tsx +3933 -0
- package/src/ui/UIFilePreview/WordEditor/style.ts +1213 -0
- package/src/ui/UIFilePreview/WordEditor/word-editor.css +999 -0
- package/src/ui/UIFilePreview/filePreview.css +193 -0
- package/src/ui/UIFilePreview/filetypes/archive.tsx +66 -0
- package/src/ui/UIFilePreview/filetypes/cad.tsx +287 -0
- package/src/ui/UIFilePreview/filetypes/excel.tsx +355 -0
- package/src/ui/UIFilePreview/filetypes/image.tsx +20 -0
- package/src/ui/UIFilePreview/filetypes/imageLoader.ts +63 -0
- package/src/ui/UIFilePreview/filetypes/pdf.tsx +156 -0
- package/src/ui/UIFilePreview/filetypes/ppt.tsx +535 -0
- package/src/ui/UIFilePreview/filetypes/pptExportPdf.ts +523 -0
- package/src/ui/UIFilePreview/filetypes/video.tsx +482 -0
- package/src/ui/UIFilePreview/filetypes/word.tsx +245 -0
- package/src/ui/UIFilePreview/filetypes/wordExportMarkdown.ts +86 -0
- package/src/ui/UIFilePreview/filetypes/wordExportPdf.tsx +427 -0
- package/src/ui/UIFilePreview/index.ts +35 -0
- package/src/ui/UIFilePreview/pdfPagination.ts +143 -0
- package/src/ui/UIFilePreview/previewUtils.tsx +531 -0
- package/src/ui/UIFileUpload.tsx +382 -0
- package/src/ui/UIImageUpload.tsx +195 -0
- package/src/ui/UILoading.tsx +255 -0
- package/src/ui/UILoadingExample.tsx +139 -0
- package/src/ui/UISelect.tsx +105 -0
- package/src/ui/UIToast.tsx +381 -0
- package/src/ui/UIToastExample.tsx +69 -0
- package/src/ui/UIToastSingle.tsx +279 -0
- package/src/ui/UIViewToggle.tsx +64 -0
- package/src/ui/UserSelector/index.tsx +583 -0
- package/src/ui/index.ts +84 -0
- package/src/ui/upload/image.ts +135 -0
- package/src/ui/upload/index.ts +73 -0
- package/src/ui/upload/submitFile.ts +106 -0
- package/src/ui/upload/submitForm.ts +124 -0
- package/src/ui/upload/upload.err.ts +15 -0
- package/src/ui/upload/upload.ts +250 -0
- package/src/utils/CacheDepartmentInfo.ts +372 -0
- package/src/utils/CacheFileContent.ts +315 -0
- package/src/utils/CachePermissionInfo.ts +235 -0
- package/src/utils/CacheRoleInfo.ts +303 -0
- package/src/utils/CacheUserInfo.ts +818 -0
- package/src/utils/CacheViewToggleInfo.ts +182 -0
- package/src/utils/MessageContentUtils.ts +268 -0
- package/src/utils/clipboard.ts +70 -0
- package/src/utils/companyEmail.ts +19 -0
- package/src/utils/forbidDebugger.ts +59 -0
- package/src/utils/formatters.ts +94 -0
- package/src/utils/htmlUtils.tsx +59 -0
- package/src/utils/index.ts +18 -0
- package/src/utils/keyboard.ts +114 -0
- package/src/utils/mammothApi.ts +16 -0
- package/src/utils/polyfills.ts +928 -0
- package/src/utils/regex.ts +14 -0
- package/src/utils/storage.ts +95 -0
- package/src/utils/workerBootstrap.ts +87 -0
- package/dist/api/index.js +0 -1553
- package/dist/contract.js +0 -5
- package/dist/index.js +0 -4492
- package/dist/types/index.js +0 -49
- package/dist/ui/index.css +0 -1876
- package/dist/ui/index.js +0 -22245
- package/dist/utils/index.js +0 -4296
- /package/{dist → src}/styles/ant-modal.css +0 -0
- /package/{dist → src}/styles/ant-spin.css +0 -0
- /package/{dist → src}/styles/ant-table.css +0 -0
- /package/{dist → src}/styles/index.css +0 -0
|
@@ -0,0 +1,2532 @@
|
|
|
1
|
+
import React, { useCallback, useRef, useMemo, useState, useEffect } from 'react';
|
|
2
|
+
import { useSearchParams } from 'react-router-dom';
|
|
3
|
+
import { message, Modal, DatePicker, Input } from 'antd';
|
|
4
|
+
import dayjs, { Dayjs } from 'dayjs';
|
|
5
|
+
import { API } from '../../../api/index';
|
|
6
|
+
import { ResponseCode } from '../../../api/_base';
|
|
7
|
+
import { ProjectFile, ProjectRole, FileManagerApi, FileManagerFeatures, FileSortBy } from '../types';
|
|
8
|
+
import { projectFileManagerApi } from '../projectFileManagerApi';
|
|
9
|
+
import { CacheUserInfo } from '../../../utils/CacheUserInfo';
|
|
10
|
+
import { CachePermissionInfo, PERMISSION_FOLDER_DOWNLOAD } from '../../../utils/CachePermissionInfo';
|
|
11
|
+
import { CacheFileContent, CacheFileMeta } from '../../../utils/CacheFileContent';
|
|
12
|
+
import { buildFileTree, getDescendantIds, getFileDescendants, sortFiles } from '../utils/fileTree';
|
|
13
|
+
import { uploadFileBlob, uploadFileDelta, uploadMarkdownDelta, sanitizeFileName, getFileExtension, stripFileExtension, getMimeType, calculateCRC32, extractFilesFromDrop } from '../utils/fileUpload';
|
|
14
|
+
import { uiFilePreview } from '../../UIFilePreview/index';
|
|
15
|
+
import uiConfirm from '../../UIConfirm';
|
|
16
|
+
import { getViewMode, setViewMode as saveViewMode } from '../../../utils/CacheViewToggleInfo';
|
|
17
|
+
import { copyToClipboard } from '../../../utils/clipboard';
|
|
18
|
+
import {
|
|
19
|
+
listLocalFilesByProject,
|
|
20
|
+
createLocalOnlyFile,
|
|
21
|
+
saveUnsyncedServerFile,
|
|
22
|
+
updateLocalFileContent,
|
|
23
|
+
markLocalFileSynced,
|
|
24
|
+
deleteLocalFile,
|
|
25
|
+
getLocalFileByServerId,
|
|
26
|
+
getLocalFile,
|
|
27
|
+
LocalFileRecord,
|
|
28
|
+
} from '../../UIFilePreview/IndexedDBUtils';
|
|
29
|
+
import uiToast from '../../UIToast';
|
|
30
|
+
import { CONTRACTS_VIRTUAL_FOLDER_ID, filterNotesRootFiles } from '../utils/fileUtils';
|
|
31
|
+
|
|
32
|
+
const PAGE_KEY = 'file-manager';
|
|
33
|
+
const FILE_DESCS_MAX_LENGTH = 255;
|
|
34
|
+
const CONTRACTS_FOLDER_NAME = '__contracts__';
|
|
35
|
+
|
|
36
|
+
/** 冲突检查结果:no-conflict 走差分;overwrite 走全量覆盖;cancel 终止上传 */
|
|
37
|
+
type ConflictCheckResult = 'no-conflict' | 'overwrite' | 'cancel';
|
|
38
|
+
|
|
39
|
+
/** 冲突检查 + 服务器最新元数据 */
|
|
40
|
+
interface ConflictCheckInfo {
|
|
41
|
+
result: ConflictCheckResult;
|
|
42
|
+
/** 服务器最新文件 CRC32(如果能拿到) */
|
|
43
|
+
serverCrc32?: number;
|
|
44
|
+
/** 服务器最新 fileResourcePath(如果能拿到) */
|
|
45
|
+
serverFileResourcePath?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 上传前冲突检查:对比编辑开始时的 uploadTime 与服务端最新 uploadTime。
|
|
50
|
+
* 若服务端文件已被其他用户更新,弹框询问是否覆盖。
|
|
51
|
+
* 同时返回服务器最新 crc32,用于校验差分基准是否与服务端一致。
|
|
52
|
+
*/
|
|
53
|
+
const checkFileUpdateConflict = async (api: FileManagerApi, editingFile: ProjectFile): Promise<ConflictCheckInfo> => {
|
|
54
|
+
// 本地新建文件无原版本可对比,直接放行
|
|
55
|
+
if (editingFile.isLocalOnly) return { result: 'no-conflict' };
|
|
56
|
+
try {
|
|
57
|
+
const res = await api.getDetail(editingFile.id);
|
|
58
|
+
if (res.code !== ResponseCode.SUCCESS || !res.data) {
|
|
59
|
+
return { result: 'no-conflict' };
|
|
60
|
+
}
|
|
61
|
+
const baseTime = new Date(editingFile.uploadTime).getTime();
|
|
62
|
+
const latestTime = new Date(res.data.uploadTime).getTime();
|
|
63
|
+
if (!isNaN(baseTime) && !isNaN(latestTime) && latestTime > baseTime) {
|
|
64
|
+
const confirmed = await uiConfirm.confirm('已经有其他用户更新了文件,是否覆盖?');
|
|
65
|
+
return {
|
|
66
|
+
result: confirmed ? 'overwrite' : 'cancel',
|
|
67
|
+
serverCrc32: res.data.crc32,
|
|
68
|
+
serverFileResourcePath: res.data.fileResourcePath,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
result: 'no-conflict',
|
|
73
|
+
serverCrc32: res.data.crc32,
|
|
74
|
+
serverFileResourcePath: res.data.fileResourcePath,
|
|
75
|
+
};
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.warn('检查文件更新冲突失败:', err);
|
|
78
|
+
}
|
|
79
|
+
return { result: 'no-conflict' };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 上传成功后获取服务端最新 uploadTime,用于更新 editingFile。
|
|
84
|
+
* 避免下次冲突检测把自己刚上传的误判为"他人更新"。
|
|
85
|
+
*/
|
|
86
|
+
const fetchLatestFileMeta = async (api: FileManagerApi, fileId: string): Promise<{ uploadTime: Date; updateTime: Date } | null> => {
|
|
87
|
+
try {
|
|
88
|
+
const res = await api.getDetail(fileId);
|
|
89
|
+
if (res.code === ResponseCode.SUCCESS && res.data) {
|
|
90
|
+
return { uploadTime: res.data.uploadTime, updateTime: res.data.updateTime };
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.warn('获取最新文件元数据失败:', err);
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
type ViewMode = 'table' | 'grid';
|
|
99
|
+
type CreatingFileType = 'pptx' | 'md' | 'xlsx' | 'docx' | 'rdoc' | null;
|
|
100
|
+
|
|
101
|
+
/** 新建文件类型对应的目标扩展名(含点号),用于新建对话框保留正确扩展名 */
|
|
102
|
+
const CREATE_FILE_EXT_MAP: Record<Exclude<CreatingFileType, null>, string> = {
|
|
103
|
+
pptx: '.pptx',
|
|
104
|
+
md: '.md',
|
|
105
|
+
xlsx: '.xlsx',
|
|
106
|
+
docx: '.docx',
|
|
107
|
+
rdoc: '.rdoc',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 将合同附件路径转换为虚拟 ProjectFile(合同子文件夹 + 链接文件)。
|
|
112
|
+
* 每个合同创建一个以合同名称命名的虚拟子文件夹,链接文件放在子文件夹内。
|
|
113
|
+
* fileResourcePaths 是 JSON 字符串数组,每项为文件名;
|
|
114
|
+
* 完整资源路径为 /private/projects/{projectId}/contracts/{contractId}/seal/{fileName}
|
|
115
|
+
* 未签约合同没有盖章文件,展示其最新版本的合同文件作为链接文件。
|
|
116
|
+
*/
|
|
117
|
+
async function buildContractLinkFiles(
|
|
118
|
+
contracts: Array<{ id: string; projectId: string; name: string; tag?: string; status?: string; fileResourcePaths: string; updateTime: Date; createBy: string }>,
|
|
119
|
+
projectId: string,
|
|
120
|
+
): Promise<ProjectFile[]> {
|
|
121
|
+
const result: ProjectFile[] = [];
|
|
122
|
+
for (const contract of contracts) {
|
|
123
|
+
const signed = contract.status === 'SIGNED';
|
|
124
|
+
const contractTag = contract.tag || '未分类';
|
|
125
|
+
|
|
126
|
+
// 使用合同 ID 作为文件夹 fileName 以保证路径唯一性,descs 存储合同名称用于显示
|
|
127
|
+
// 未签约合同没有盖章文件,但同样生成子文件夹以便在文件列表中展示(弱化显示)
|
|
128
|
+
const subfolderId = `contract-folder-${contract.id}`;
|
|
129
|
+
const subfolderPath = `/${CONTRACTS_FOLDER_NAME}/`;
|
|
130
|
+
result.push({
|
|
131
|
+
id: subfolderId,
|
|
132
|
+
isSecure: false,
|
|
133
|
+
projectId,
|
|
134
|
+
parentId: null,
|
|
135
|
+
fileName: contract.id,
|
|
136
|
+
filePath: subfolderPath,
|
|
137
|
+
fileType: 'folder',
|
|
138
|
+
fileSize: 0,
|
|
139
|
+
crc32: 0,
|
|
140
|
+
fileResourcePath: '',
|
|
141
|
+
uploaderName: '',
|
|
142
|
+
uploadTime: new Date(contract.updateTime),
|
|
143
|
+
createTime: new Date(contract.updateTime),
|
|
144
|
+
updateTime: new Date(contract.updateTime),
|
|
145
|
+
createBy: contract.createBy,
|
|
146
|
+
updateBy: contract.createBy,
|
|
147
|
+
isContractSubfolder: true,
|
|
148
|
+
contractSigned: signed,
|
|
149
|
+
contractTag,
|
|
150
|
+
descs: contract.name || '未命名合同',
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
let hasSealFiles = false;
|
|
154
|
+
if (!contract.fileResourcePaths) {
|
|
155
|
+
hasSealFiles = false;
|
|
156
|
+
} else {
|
|
157
|
+
let paths: string[] = [];
|
|
158
|
+
try {
|
|
159
|
+
paths = JSON.parse(contract.fileResourcePaths);
|
|
160
|
+
} catch {
|
|
161
|
+
hasSealFiles = false;
|
|
162
|
+
}
|
|
163
|
+
if (Array.isArray(paths) && paths.length > 0) {
|
|
164
|
+
hasSealFiles = true;
|
|
165
|
+
for (const fileName of paths) {
|
|
166
|
+
if (!fileName) continue;
|
|
167
|
+
result.push({
|
|
168
|
+
id: `contract-file-${contract.id}-${fileName}`,
|
|
169
|
+
isSecure: false,
|
|
170
|
+
projectId,
|
|
171
|
+
parentId: subfolderId,
|
|
172
|
+
fileName,
|
|
173
|
+
filePath: `${subfolderPath}${contract.id}/`,
|
|
174
|
+
fileType: getMimeType(fileName),
|
|
175
|
+
fileSize: 0,
|
|
176
|
+
crc32: 0,
|
|
177
|
+
fileResourcePath: `/private/projects/${projectId}/contracts/${contract.id}/seal/${fileName}`,
|
|
178
|
+
uploaderName: '合同',
|
|
179
|
+
uploadTime: new Date(contract.updateTime),
|
|
180
|
+
createTime: new Date(contract.updateTime),
|
|
181
|
+
updateTime: new Date(contract.updateTime),
|
|
182
|
+
createBy: contract.createBy,
|
|
183
|
+
updateBy: contract.createBy,
|
|
184
|
+
isContractLink: true,
|
|
185
|
+
contractSigned: signed,
|
|
186
|
+
contractTag,
|
|
187
|
+
descs: contract.name,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
hasSealFiles = false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 未签约合同:展示最新版本的合同文件(弱化显示)
|
|
196
|
+
if (!signed && !hasSealFiles) {
|
|
197
|
+
try {
|
|
198
|
+
const versionRes = await API.project.contract.getVersionList({ contractId: contract.id });
|
|
199
|
+
if (versionRes.code === ResponseCode.SUCCESS && versionRes.data?.length) {
|
|
200
|
+
const latest = versionRes.data.reduce((a, b) => ((b.versionNumber ?? 0) > (a.versionNumber ?? 0) ? b : a));
|
|
201
|
+
if (latest.fileResourcePath && latest.fileName) {
|
|
202
|
+
result.push({
|
|
203
|
+
id: `contract-file-${contract.id}-v${latest.versionNumber}-${latest.fileName}`,
|
|
204
|
+
isSecure: false,
|
|
205
|
+
projectId,
|
|
206
|
+
parentId: subfolderId,
|
|
207
|
+
fileName: latest.fileName,
|
|
208
|
+
filePath: `${subfolderPath}${contract.id}/`,
|
|
209
|
+
fileType: getMimeType(latest.fileName),
|
|
210
|
+
fileSize: latest.fileSize || 0,
|
|
211
|
+
crc32: 0,
|
|
212
|
+
fileResourcePath: latest.fileResourcePath,
|
|
213
|
+
uploaderName: '合同',
|
|
214
|
+
uploadTime: new Date(latest.createTime || contract.updateTime),
|
|
215
|
+
createTime: new Date(latest.createTime || contract.updateTime),
|
|
216
|
+
updateTime: new Date(latest.createTime || contract.updateTime),
|
|
217
|
+
createBy: latest.createBy || contract.createBy,
|
|
218
|
+
updateBy: latest.createBy || contract.createBy,
|
|
219
|
+
isContractLink: true,
|
|
220
|
+
contractSigned: false,
|
|
221
|
+
contractTag,
|
|
222
|
+
descs: contract.name,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch (err) {
|
|
227
|
+
console.warn('[合同文件夹] 加载未签约合同版本失败:', contract.id, err);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 创建虚拟合同文件夹节点 */
|
|
235
|
+
function createVirtualContractsFolder(projectId: string): ProjectFile {
|
|
236
|
+
return {
|
|
237
|
+
id: CONTRACTS_VIRTUAL_FOLDER_ID,
|
|
238
|
+
isSecure: false,
|
|
239
|
+
projectId,
|
|
240
|
+
parentId: null,
|
|
241
|
+
fileName: CONTRACTS_FOLDER_NAME,
|
|
242
|
+
filePath: '/',
|
|
243
|
+
fileType: 'folder',
|
|
244
|
+
fileSize: 0,
|
|
245
|
+
crc32: 0,
|
|
246
|
+
fileResourcePath: '',
|
|
247
|
+
uploaderName: '',
|
|
248
|
+
uploadTime: new Date(),
|
|
249
|
+
createTime: new Date(),
|
|
250
|
+
updateTime: new Date(),
|
|
251
|
+
createBy: '',
|
|
252
|
+
updateBy: '',
|
|
253
|
+
isVirtualContractFolder: true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* "按类型"排序时的合同布局:在合同文件夹一级生成以分类标签命名的虚拟文件夹,
|
|
259
|
+
* 将各合同子文件夹及其附件移动到对应标签文件夹下。
|
|
260
|
+
* 其他排序方式保持原有扁平结构(合同子文件夹直接位于合同文件夹下)。
|
|
261
|
+
*/
|
|
262
|
+
function layoutContractFilesByType(
|
|
263
|
+
files: ProjectFile[],
|
|
264
|
+
sortBy: FileSortBy,
|
|
265
|
+
): ProjectFile[] {
|
|
266
|
+
if (sortBy !== 'fileType') return files;
|
|
267
|
+
if (!files.some((f) => f.isContractSubfolder || f.isContractLink)) return files;
|
|
268
|
+
|
|
269
|
+
const rootPath = `/${CONTRACTS_FOLDER_NAME}`;
|
|
270
|
+
const contractsPrefix = `${rootPath}/`;
|
|
271
|
+
|
|
272
|
+
// 合同 ID -> 分类标签(子文件夹的 fileName 即合同 ID)
|
|
273
|
+
const contractTagMap = new Map<string, string>();
|
|
274
|
+
files.forEach((f) => {
|
|
275
|
+
if (f.isContractSubfolder) {
|
|
276
|
+
contractTagMap.set(f.fileName, f.contractTag || '未分类');
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const result: ProjectFile[] = [];
|
|
281
|
+
const tagFolders = new Map<string, ProjectFile>();
|
|
282
|
+
const ensureTagFolder = (tag: string, projectId: string, time: Date): string => {
|
|
283
|
+
const tagFolderId = `contract-tag-${tag}`;
|
|
284
|
+
let folder = tagFolders.get(tag);
|
|
285
|
+
if (!folder) {
|
|
286
|
+
folder = {
|
|
287
|
+
id: tagFolderId,
|
|
288
|
+
isSecure: false,
|
|
289
|
+
projectId,
|
|
290
|
+
parentId: null,
|
|
291
|
+
fileName: tag,
|
|
292
|
+
filePath: contractsPrefix,
|
|
293
|
+
fileType: 'folder',
|
|
294
|
+
fileSize: 0,
|
|
295
|
+
crc32: 0,
|
|
296
|
+
fileResourcePath: '',
|
|
297
|
+
uploaderName: '',
|
|
298
|
+
uploadTime: time,
|
|
299
|
+
createTime: time,
|
|
300
|
+
updateTime: time,
|
|
301
|
+
createBy: '',
|
|
302
|
+
updateBy: '',
|
|
303
|
+
isContractTagFolder: true,
|
|
304
|
+
};
|
|
305
|
+
tagFolders.set(tag, folder);
|
|
306
|
+
result.push(folder);
|
|
307
|
+
} else if (new Date(time).getTime() > new Date(folder.uploadTime).getTime()) {
|
|
308
|
+
folder.uploadTime = time;
|
|
309
|
+
folder.updateTime = time;
|
|
310
|
+
}
|
|
311
|
+
return tagFolderId;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
for (const f of files) {
|
|
315
|
+
if (f.isContractSubfolder) {
|
|
316
|
+
const tag = f.contractTag || '未分类';
|
|
317
|
+
const tagFolderId = ensureTagFolder(tag, f.projectId, new Date(f.uploadTime));
|
|
318
|
+
result.push({ ...f, filePath: `${contractsPrefix}${tag}/`, parentId: tagFolderId });
|
|
319
|
+
} else if (f.filePath && f.filePath.startsWith(contractsPrefix)) {
|
|
320
|
+
// 合同子文件夹下的链接文件/真实文件:{prefix}{contractId}/... -> {prefix}{tag}/{contractId}/...
|
|
321
|
+
const rest = f.filePath.substring(contractsPrefix.length);
|
|
322
|
+
const contractId = rest.split('/')[0];
|
|
323
|
+
const tag = f.contractTag || contractTagMap.get(contractId);
|
|
324
|
+
if (tag) {
|
|
325
|
+
ensureTagFolder(tag, f.projectId, new Date(f.uploadTime));
|
|
326
|
+
result.push({ ...f, filePath: `${contractsPrefix}${tag}/${rest}` });
|
|
327
|
+
} else {
|
|
328
|
+
result.push(f);
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
result.push(f);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return result;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 将本地文件记录(IndexedDB localFiles store)映射为可在文件列表中显示的 ProjectFile */
|
|
338
|
+
function localFileRecordToProjectFile(local: LocalFileRecord): ProjectFile {
|
|
339
|
+
const now = new Date(local.updatedAt);
|
|
340
|
+
return {
|
|
341
|
+
id: local.localId, // 本地文件使用 localId 作为列表 id;避免与服务器文件 id 重复
|
|
342
|
+
localId: local.localId,
|
|
343
|
+
isLocalOnly: local.isLocalOnly,
|
|
344
|
+
hasUnsyncedChanges: local.hasUnsyncedChanges,
|
|
345
|
+
isSecure: false,
|
|
346
|
+
projectId: local.projectId,
|
|
347
|
+
parentId: local.parentId,
|
|
348
|
+
fileName: local.fileName,
|
|
349
|
+
filePath: '/',
|
|
350
|
+
fileType: local.fileType,
|
|
351
|
+
fileSize: local.fileSize,
|
|
352
|
+
crc32: 0,
|
|
353
|
+
hasLocalCache: true,
|
|
354
|
+
fileResourcePath: '',
|
|
355
|
+
uploaderName: '我',
|
|
356
|
+
uploadTime: now,
|
|
357
|
+
createTime: new Date(local.createdAt),
|
|
358
|
+
updateTime: now,
|
|
359
|
+
createBy: local.userId,
|
|
360
|
+
updateBy: local.userId,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 复制失败时(如部分移动端 webview)弹出可手动选中复制的输入框
|
|
365
|
+
function showLinkCopyModal(link: string) {
|
|
366
|
+
const inputRef: { current: any } = { current: null };
|
|
367
|
+
Modal.info({
|
|
368
|
+
title: '请手动复制链接',
|
|
369
|
+
content: React.createElement(Input, {
|
|
370
|
+
value: link,
|
|
371
|
+
readOnly: true,
|
|
372
|
+
ref: (input: any) => { inputRef.current = input; },
|
|
373
|
+
onFocus: (e: any) => e.target.select(),
|
|
374
|
+
}),
|
|
375
|
+
okText: '关闭',
|
|
376
|
+
width: 480,
|
|
377
|
+
afterClose: () => {},
|
|
378
|
+
onOk: () => {},
|
|
379
|
+
});
|
|
380
|
+
// 延迟聚焦选中,确保弹窗已渲染
|
|
381
|
+
setTimeout(() => {
|
|
382
|
+
inputRef.current?.focus?.();
|
|
383
|
+
inputRef.current?.select?.();
|
|
384
|
+
}, 100);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
interface FileManagerState {
|
|
388
|
+
files: ProjectFile[];
|
|
389
|
+
expandedRowKeys: string[];
|
|
390
|
+
selectedFolderId: string | null;
|
|
391
|
+
dragOverId: string | null;
|
|
392
|
+
/** 排序字段(与 useFileManagerState 中定义保持一致) */
|
|
393
|
+
sortBy: FileSortBy;
|
|
394
|
+
/** 当前用户是否具备项目管理权限(文件夹下载等入口) */
|
|
395
|
+
isManager: boolean;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
interface FileManagerStateActions {
|
|
399
|
+
setFiles: (files: ProjectFile[]) => void;
|
|
400
|
+
setFileTreeData: (data: ProjectFile[]) => void;
|
|
401
|
+
setLoading: (loading: boolean) => void;
|
|
402
|
+
setExpandedRowKeys: (keys: string[]) => void;
|
|
403
|
+
setViewMode: (mode: ViewMode) => void;
|
|
404
|
+
setSelectedFolderId: (id: string | null) => void;
|
|
405
|
+
setUploadPercent: (percent: number | undefined) => void;
|
|
406
|
+
setUploadSuccess: (success: boolean | undefined) => void;
|
|
407
|
+
setDragUploadPercent: (percent: number | undefined) => void;
|
|
408
|
+
setDragUploadSuccess: (success: boolean | undefined) => void;
|
|
409
|
+
setDragUploadingFileName: (name: string) => void;
|
|
410
|
+
setIsExternalDragOver: (over: boolean) => void;
|
|
411
|
+
setNewFolderName: (name: string) => void;
|
|
412
|
+
setIsManager: (manager: boolean) => void;
|
|
413
|
+
setEditingFile: (file: ProjectFile | null) => void;
|
|
414
|
+
setEditorVisible: (visible: boolean) => void;
|
|
415
|
+
setPreviewFileName: (name: string | null) => void;
|
|
416
|
+
setPreviewUrl: (url: string | null) => void;
|
|
417
|
+
setPreviewVisible: (visible: boolean) => void;
|
|
418
|
+
setPreviewingFile: (file: ProjectFile | null) => void;
|
|
419
|
+
setPreviewCrc32: (crc32: number | undefined) => void;
|
|
420
|
+
setShareToWechatFile: (file: ProjectFile | null) => void;
|
|
421
|
+
setShareToWechatVisible: (visible: boolean) => void;
|
|
422
|
+
setRenameFileId: (id: string | null) => void;
|
|
423
|
+
setRenameFileName: (name: string) => void;
|
|
424
|
+
setRenameVisible: (visible: boolean) => void;
|
|
425
|
+
setDescsFileId: (id: string | null) => void;
|
|
426
|
+
setDescsValue: (value: string) => void;
|
|
427
|
+
setDescsVisible: (visible: boolean) => void;
|
|
428
|
+
setIsEncryptedFolder: (encrypted: boolean) => void;
|
|
429
|
+
setCreateFolderVisible: (visible: boolean) => void;
|
|
430
|
+
setNewFolderDescs: (descs: string) => void;
|
|
431
|
+
setCreatingFileName: (name: string) => void;
|
|
432
|
+
setCreatingFileType: (type: CreatingFileType) => void;
|
|
433
|
+
setCreatorVisible: (visible: boolean) => void;
|
|
434
|
+
setContextMenuVisible: (visible: boolean) => void;
|
|
435
|
+
setContextMenuRecord: (record: ProjectFile | null) => void;
|
|
436
|
+
setContextMenuPosition: (pos: { x: number; y: number }) => void;
|
|
437
|
+
setMoveCopyFiles: (files: ProjectFile[]) => void;
|
|
438
|
+
setMoveCopyMode: (mode: 'move' | 'copy') => void;
|
|
439
|
+
setMoveCopyModalVisible: (visible: boolean) => void;
|
|
440
|
+
setVersionHistoryFile: (file: ProjectFile | null) => void;
|
|
441
|
+
setVersionHistoryModalVisible: (visible: boolean) => void;
|
|
442
|
+
setDragSourceId: (id: string | null) => void;
|
|
443
|
+
setDragOverId: (id: string | null) => void;
|
|
444
|
+
setRootDragOver: (over: boolean) => void;
|
|
445
|
+
setIsOnline: (online: boolean) => void;
|
|
446
|
+
setPreviewLocalId: (localId: string | null) => void;
|
|
447
|
+
setPreviewIsLocalOnly: (isLocalOnly: boolean) => void;
|
|
448
|
+
setPreviewHasUnsyncedChanges: (has: boolean) => void;
|
|
449
|
+
setCreateFileVisible: (visible: boolean) => void;
|
|
450
|
+
setNewFileName: (name: string) => void;
|
|
451
|
+
setPendingFileType: (type: CreatingFileType) => void;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
interface FileManagerActions {
|
|
455
|
+
loadFiles: () => Promise<void>;
|
|
456
|
+
uploadFiles: (fileList: any[]) => Promise<void>;
|
|
457
|
+
handlePreview: (file: ProjectFile) => void;
|
|
458
|
+
handleDownload: (file: ProjectFile) => void;
|
|
459
|
+
/** 发起文件夹异步打包下载(仅管理员/负责人可用) */
|
|
460
|
+
handleDownloadFolder: (file: ProjectFile) => void;
|
|
461
|
+
handleShareToWechat: (file: ProjectFile) => void;
|
|
462
|
+
handleCopyLink: (file: ProjectFile) => void;
|
|
463
|
+
handleJumpToContract: (file: ProjectFile) => void;
|
|
464
|
+
/** 在合同根文件夹右键菜单中点击"创建合同":切换到合同页签并打开新建合同弹窗 */
|
|
465
|
+
handleCreateContract: (file: ProjectFile) => void;
|
|
466
|
+
handleGenerateTempLink: (file: ProjectFile) => void;
|
|
467
|
+
handleDelete: (fileId: string) => void;
|
|
468
|
+
isFolderExpanded: (folderId: string) => boolean;
|
|
469
|
+
handleMove: (fileId: string, targetFolderId: string | null) => void;
|
|
470
|
+
handleRename: (file: ProjectFile) => void;
|
|
471
|
+
handleEditDescs: (file: ProjectFile) => void;
|
|
472
|
+
handleDescsConfirm: (descsFileId: string | null, descsValue: string) => void;
|
|
473
|
+
handleRenameConfirm: (renameFileId: string | null, renameFileName: string) => void;
|
|
474
|
+
handleCreateFolder: () => void;
|
|
475
|
+
openCreator: (fileName: string, fileType: CreatingFileType) => void;
|
|
476
|
+
handleCreatePptx: () => void;
|
|
477
|
+
handleCreateMarkdown: () => void;
|
|
478
|
+
handleCreateRdoc: () => void;
|
|
479
|
+
handleCreateExcel: () => void;
|
|
480
|
+
handleCreateWord: () => void;
|
|
481
|
+
handleCreateFileConfirm: (fileName: string, fileType: CreatingFileType) => void;
|
|
482
|
+
handleCreateFolderConfirm: (newFolderName: string, newFolderDescs: string, isEncryptedFolder: boolean) => void;
|
|
483
|
+
handleEditFile: (file: ProjectFile) => void;
|
|
484
|
+
handleUploadBlob: (editingFile: ProjectFile | null, initialBlob: Blob | undefined, blob: Blob, fileName: string) => void;
|
|
485
|
+
handleUploadMarkdown: (editingFile: ProjectFile | null, oldContent: string, content: string, fileName: string) => void;
|
|
486
|
+
handleCreateUploadBlob: (initialBlob: Blob | undefined, blob: Blob, fileName: string) => void;
|
|
487
|
+
handleCreateUploadMarkdown: (oldContent: string, content: string, fileName: string) => void;
|
|
488
|
+
/** 编辑器点击"保存本地缓存"时调用:同步更新 localFiles store 内容,使 hasUnsyncedChanges 标记生效 */
|
|
489
|
+
handleSaveLocalBlob: (localId: string | null | undefined, blob: Blob, fileName: string, markUnsynced?: boolean) => Promise<void>;
|
|
490
|
+
/** Markdown 编辑器点击"保存本地缓存"时调用 */
|
|
491
|
+
handleSaveLocalMarkdown: (localId: string | null | undefined, content: string, fileName: string, markUnsynced?: boolean) => Promise<void>;
|
|
492
|
+
handleExpand: (expanded: boolean, record: ProjectFile) => void;
|
|
493
|
+
toggleFolderExpand: (record: ProjectFile, tableView: boolean) => void;
|
|
494
|
+
handleDragStart: (fileId: string) => void;
|
|
495
|
+
handleDragEnd: () => void;
|
|
496
|
+
handleDragOver: (fileId: string) => void;
|
|
497
|
+
handleDragLeave: (fileId: string) => void;
|
|
498
|
+
handleDrop: (sourceId: string, targetId: string) => void;
|
|
499
|
+
handleExternalDragEnter: (e: React.DragEvent) => void;
|
|
500
|
+
handleExternalDragOver: (e: React.DragEvent) => void;
|
|
501
|
+
handleExternalDragLeave: (e: React.DragEvent) => void;
|
|
502
|
+
handleExternalDrop: (e: React.DragEvent) => void;
|
|
503
|
+
handleContextMenu: (event: React.MouseEvent, record: ProjectFile) => void;
|
|
504
|
+
handleMoveToProject: (file: ProjectFile) => void;
|
|
505
|
+
handleCopyToProject: (file: ProjectFile) => void;
|
|
506
|
+
handleCopyToSelf: (file: ProjectFile) => void;
|
|
507
|
+
handleMoveCopySuccess: () => void;
|
|
508
|
+
handleViewVersionHistory: (file: ProjectFile) => void;
|
|
509
|
+
handleClearCache: (file: ProjectFile) => void;
|
|
510
|
+
handleCacheSaved: (fileId: string) => void;
|
|
511
|
+
handleOpenInNewWindow: (file: ProjectFile) => void;
|
|
512
|
+
handleRootDragOver: (e: React.DragEvent) => void;
|
|
513
|
+
handleRootDragLeave: () => void;
|
|
514
|
+
handleRootDrop: (e: React.DragEvent) => void;
|
|
515
|
+
initUserRole: () => void;
|
|
516
|
+
initViewMode: () => void;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export const useFileManagerActions = (
|
|
520
|
+
projectId: string,
|
|
521
|
+
currentUserRole: ProjectRole | null,
|
|
522
|
+
state: FileManagerState,
|
|
523
|
+
actions: FileManagerStateActions,
|
|
524
|
+
options?: {
|
|
525
|
+
/** 数据接口适配器,缺省为项目文件 API */
|
|
526
|
+
api?: FileManagerApi;
|
|
527
|
+
/** 功能开关,缺省全部开启 */
|
|
528
|
+
features?: FileManagerFeatures;
|
|
529
|
+
}
|
|
530
|
+
): FileManagerActions => {
|
|
531
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
532
|
+
const externalDragCounter = useRef(0);
|
|
533
|
+
const actionsRef = useRef(actions);
|
|
534
|
+
const stateRef = useRef(state);
|
|
535
|
+
const handleCacheSavedRef = useRef<(fileId: string) => void>(() => {});
|
|
536
|
+
/** 文件夹打包下载轮询定时器(jobId → timer) */
|
|
537
|
+
const folderDownloadTimersRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
|
|
538
|
+
/** 最近一次 loadFiles 的原始扁平列表(未应用合同标签分组布局),排序切换时基于它重新布局 */
|
|
539
|
+
const rawFilesRef = useRef<ProjectFile[]>([]);
|
|
540
|
+
actionsRef.current = actions;
|
|
541
|
+
stateRef.current = state;
|
|
542
|
+
|
|
543
|
+
// 数据接口与功能开关(缺省项目实现、全部功能开启)
|
|
544
|
+
const api = options?.api || projectFileManagerApi;
|
|
545
|
+
const features = options?.features || {};
|
|
546
|
+
const enableContracts = features.contracts !== false;
|
|
547
|
+
const apiRef = useRef(api);
|
|
548
|
+
apiRef.current = api;
|
|
549
|
+
|
|
550
|
+
// 排序方式变化时重新应用合同标签分组布局("按类型"排序时合同收进分类标签文件夹),无需重新请求服务器
|
|
551
|
+
useEffect(() => {
|
|
552
|
+
if (rawFilesRef.current.length === 0) return;
|
|
553
|
+
const finalFiles = sortFiles(layoutContractFilesByType(rawFilesRef.current, stateRef.current.sortBy));
|
|
554
|
+
actionsRef.current.setFileTreeData(buildFileTree(finalFiles));
|
|
555
|
+
actionsRef.current.setFiles(finalFiles);
|
|
556
|
+
}, [state.sortBy]);
|
|
557
|
+
|
|
558
|
+
// 组件卸载时清理所有文件夹打包下载轮询
|
|
559
|
+
useEffect(() => {
|
|
560
|
+
const timers = folderDownloadTimersRef.current;
|
|
561
|
+
return () => {
|
|
562
|
+
timers.forEach((timer) => clearInterval(timer));
|
|
563
|
+
timers.clear();
|
|
564
|
+
};
|
|
565
|
+
}, []);
|
|
566
|
+
|
|
567
|
+
// 网络状态监听:在线/离线事件同步到 React state
|
|
568
|
+
useEffect(() => {
|
|
569
|
+
const handleOnline = () => actionsRef.current.setIsOnline(true);
|
|
570
|
+
const handleOffline = () => actionsRef.current.setIsOnline(false);
|
|
571
|
+
window.addEventListener('online', handleOnline);
|
|
572
|
+
window.addEventListener('offline', handleOffline);
|
|
573
|
+
actionsRef.current.setIsOnline(typeof navigator !== 'undefined' ? navigator.onLine : true);
|
|
574
|
+
return () => {
|
|
575
|
+
window.removeEventListener('online', handleOnline);
|
|
576
|
+
window.removeEventListener('offline', handleOffline);
|
|
577
|
+
};
|
|
578
|
+
}, []);
|
|
579
|
+
|
|
580
|
+
const loadFiles = useCallback(async () => {
|
|
581
|
+
actionsRef.current.setLoading(true);
|
|
582
|
+
try {
|
|
583
|
+
// 1. 先加载本地文件记录(IndexedDB localFiles store)
|
|
584
|
+
// 离线时用于重建服务器文件列表;在线时用于标记未同步修改
|
|
585
|
+
const userInfo = await CacheUserInfo.getUserInfo({ forceRefresh: false });
|
|
586
|
+
const userId = userInfo?.id || 'local-user';
|
|
587
|
+
let localRecords: LocalFileRecord[] = [];
|
|
588
|
+
try {
|
|
589
|
+
localRecords = await listLocalFilesByProject(userId, projectId);
|
|
590
|
+
} catch (err) {
|
|
591
|
+
console.warn('加载本地文件列表失败:', err);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// 2. 加载服务器文件(在线请求 API;离线时用本地持久化的元数据缓存重建列表)
|
|
595
|
+
let serverFiles: ProjectFile[] = [];
|
|
596
|
+
const isOnlineFlag = typeof navigator !== 'undefined' ? navigator.onLine : true;
|
|
597
|
+
if (isOnlineFlag) {
|
|
598
|
+
const response = await api.listFiles(projectId);
|
|
599
|
+
if (response.code === ResponseCode.SUCCESS && response.data) {
|
|
600
|
+
serverFiles = response.data as ProjectFile[];
|
|
601
|
+
}
|
|
602
|
+
// 在线时把服务器返回的所有元数据都持久化到 CacheFileMeta,供离线时使用
|
|
603
|
+
CacheFileMeta.saveFileMetaBatch(projectId, serverFiles).catch(err =>
|
|
604
|
+
console.warn('持久化文件元数据缓存失败:', err)
|
|
605
|
+
);
|
|
606
|
+
} else {
|
|
607
|
+
// 离线:多种来源合并重建服务器文件列表
|
|
608
|
+
// a) CacheFileMeta 中按项目保存的完整元数据(每次 listFiles 在线时写入)
|
|
609
|
+
// b) localFiles 中服务器文件记录带的 originalServerFile 快照
|
|
610
|
+
// c) CacheFileContent 中有 Blob 缓存但 a/b 都没覆盖到的 fileId,用 CacheFileMeta.getFileMeta 单条查
|
|
611
|
+
const metaFromBatch: ProjectFile[] = await CacheFileMeta.listFileMetaByProject(projectId);
|
|
612
|
+
const metaFromLocalFiles: ProjectFile[] = localRecords
|
|
613
|
+
.filter(l => !l.isLocalOnly && l.serverFileId && l.originalServerFile)
|
|
614
|
+
.map(l => l.originalServerFile as ProjectFile);
|
|
615
|
+
|
|
616
|
+
// c) 补齐 CacheFileContent 中存在但 a/b 没有的元数据
|
|
617
|
+
const existingIds = new Set([
|
|
618
|
+
...metaFromBatch.map(f => f.id),
|
|
619
|
+
...metaFromLocalFiles.map(f => f.id),
|
|
620
|
+
]);
|
|
621
|
+
const contentIds: string[] = (await CacheFileContent.getCacheList())
|
|
622
|
+
.map(r => r.fileId)
|
|
623
|
+
.filter(id => !existingIds.has(id));
|
|
624
|
+
const extraFromMeta: ProjectFile[] = [];
|
|
625
|
+
for (const cid of contentIds) {
|
|
626
|
+
const m = await CacheFileMeta.getFileMeta(cid);
|
|
627
|
+
if (m && m.projectId === projectId) extraFromMeta.push(m);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// 合并去重:以 metaFromBatch 优先,metaFromLocalFiles 次之,extra 再补
|
|
631
|
+
const merged: Map<string, ProjectFile> = new Map();
|
|
632
|
+
for (const f of metaFromBatch) merged.set(f.id, f);
|
|
633
|
+
for (const f of metaFromLocalFiles) if (!merged.has(f.id)) merged.set(f.id, f);
|
|
634
|
+
for (const f of extraFromMeta) if (!merged.has(f.id)) merged.set(f.id, f);
|
|
635
|
+
serverFiles = Array.from(merged.values());
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const localOnlyProjectFiles: ProjectFile[] = localRecords
|
|
639
|
+
.filter(l => l.isLocalOnly)
|
|
640
|
+
.map(localFileRecordToProjectFile);
|
|
641
|
+
|
|
642
|
+
// 3. 标记服务器文件:hasUnsyncedChanges / localId,并预计算 hasLocalCache
|
|
643
|
+
const unsyncedServerIds = new Set(
|
|
644
|
+
localRecords.filter(l => !l.isLocalOnly && l.serverFileId && l.hasUnsyncedChanges).map(l => l.serverFileId!)
|
|
645
|
+
);
|
|
646
|
+
const serverIdToLocalRecord: Record<string, LocalFileRecord> = {};
|
|
647
|
+
for (const l of localRecords) {
|
|
648
|
+
if (l.serverFileId) serverIdToLocalRecord[l.serverFileId] = l;
|
|
649
|
+
}
|
|
650
|
+
const mergedServerFiles = await Promise.all(
|
|
651
|
+
serverFiles.map(async (sf) => {
|
|
652
|
+
const localMatch = serverIdToLocalRecord[sf.id];
|
|
653
|
+
const cacheHit = sf.fileType !== 'folder'
|
|
654
|
+
? (!!localMatch || await CacheFileContent.hasCache(sf.id))
|
|
655
|
+
: false;
|
|
656
|
+
return {
|
|
657
|
+
...sf,
|
|
658
|
+
hasUnsyncedChanges: unsyncedServerIds.has(sf.id),
|
|
659
|
+
localId: localMatch?.localId,
|
|
660
|
+
hasLocalCache: cacheHit,
|
|
661
|
+
} as ProjectFile;
|
|
662
|
+
})
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
// 4. 非私有项目:构建虚拟"合同"文件夹(始终显示,不依赖合同 API 结果)
|
|
666
|
+
const isPrivateProject = projectId === userId;
|
|
667
|
+
let contractLinkFiles: ProjectFile[] = [];
|
|
668
|
+
let contractsFolderId: string | null = null;
|
|
669
|
+
|
|
670
|
+
// 4. 合同文件夹:仅查看(viewer)成员不展示合同文件夹(后端合同列表接口对 VIEWER 同样返回空列表)
|
|
671
|
+
if (enableContracts && !isPrivateProject && currentUserRole !== 'viewer') {
|
|
672
|
+
// 查找根目录下是否已存在真实的 __contracts__ 文件夹
|
|
673
|
+
const realContractsFolder = mergedServerFiles.find(
|
|
674
|
+
f => f.fileType === 'folder' && f.fileName === CONTRACTS_FOLDER_NAME && f.filePath === '/',
|
|
675
|
+
);
|
|
676
|
+
if (realContractsFolder) {
|
|
677
|
+
realContractsFolder.isVirtualContractFolder = true;
|
|
678
|
+
contractsFolderId = realContractsFolder.id;
|
|
679
|
+
} else {
|
|
680
|
+
// 创建虚拟文件夹
|
|
681
|
+
const virtualFolder = createVirtualContractsFolder(projectId);
|
|
682
|
+
mergedServerFiles.push(virtualFolder);
|
|
683
|
+
contractsFolderId = virtualFolder.id;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// 加载合同附件(仅在在线时请求)
|
|
687
|
+
if (isOnlineFlag && contractsFolderId) {
|
|
688
|
+
try {
|
|
689
|
+
const contractRes = await API.project.contract.getContractList({
|
|
690
|
+
projectId,
|
|
691
|
+
pageNum: 1,
|
|
692
|
+
pageSize: 10000,
|
|
693
|
+
});
|
|
694
|
+
if (contractRes.code === ResponseCode.SUCCESS && contractRes.data?.list?.length) {
|
|
695
|
+
contractLinkFiles = await buildContractLinkFiles(
|
|
696
|
+
contractRes.data.list, projectId,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
} catch (err) {
|
|
700
|
+
console.warn('[合同文件夹] 加载合同列表失败:', err);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// 5. 合并并排序("按类型"排序时合同按分类标签分组到虚拟文件夹)
|
|
706
|
+
// 笔记模块的根目录($$__notes$$__)及其内容不展示在文件管理中
|
|
707
|
+
const allFiles = filterNotesRootFiles([...mergedServerFiles, ...localOnlyProjectFiles, ...contractLinkFiles]);
|
|
708
|
+
rawFilesRef.current = allFiles;
|
|
709
|
+
const sortedFiles = sortFiles(layoutContractFilesByType(allFiles, stateRef.current.sortBy));
|
|
710
|
+
actionsRef.current.setFileTreeData(buildFileTree(sortedFiles));
|
|
711
|
+
actionsRef.current.setFiles(sortedFiles);
|
|
712
|
+
} catch (error) {
|
|
713
|
+
console.error('加载文件列表失败:', error);
|
|
714
|
+
message.error('加载文件列表失败');
|
|
715
|
+
} finally {
|
|
716
|
+
actionsRef.current.setLoading(false);
|
|
717
|
+
}
|
|
718
|
+
}, [projectId, currentUserRole, api, enableContracts]);
|
|
719
|
+
|
|
720
|
+
const uploadFiles = useCallback(async (fileList: any[]) => {
|
|
721
|
+
try {
|
|
722
|
+
const { files, selectedFolderId } = stateRef.current;
|
|
723
|
+
const selectedFolder = files.find((f) => f.id === selectedFolderId);
|
|
724
|
+
const file = fileList[0];
|
|
725
|
+
|
|
726
|
+
const { crc32, serverFileName } = await uploadFileBlob(file as Blob, (file as any).name,
|
|
727
|
+
() => {},
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
const response = await api.addFile(projectId, {
|
|
731
|
+
fileName: (file as any).name,
|
|
732
|
+
filePath: selectedFolder ? `${selectedFolder.filePath}${selectedFolder.fileName}/` : `/`,
|
|
733
|
+
fileType: (file as any).type,
|
|
734
|
+
fileSize: (file as any).size,
|
|
735
|
+
crc32,
|
|
736
|
+
fileResourcePath: serverFileName
|
|
737
|
+
});
|
|
738
|
+
if (response.code === ResponseCode.SUCCESS) {
|
|
739
|
+
await CacheFileContent.saveToCache(response.data!.fileId, file as Blob, crc32, file.size);
|
|
740
|
+
loadFiles();
|
|
741
|
+
}
|
|
742
|
+
} catch (error) {
|
|
743
|
+
message.error('文件上传失败');
|
|
744
|
+
}
|
|
745
|
+
}, [projectId, api, loadFiles]);
|
|
746
|
+
|
|
747
|
+
const handlePreview = useCallback((file: ProjectFile) => {
|
|
748
|
+
if (file.fileType === 'folder') {
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ---- 本地文件:点击后直接进入编辑模式(可继续上次未完成的编辑)----
|
|
753
|
+
// 离线时,有本地缓存(localId)的服务器文件也走本地加载,确保可查看编辑
|
|
754
|
+
const isOnlineFlag = typeof navigator !== 'undefined' ? navigator.onLine : true;
|
|
755
|
+
if (file.isLocalOnly || file.hasUnsyncedChanges || (!isOnlineFlag && file.localId)) {
|
|
756
|
+
actionsRef.current.setEditingFile(file);
|
|
757
|
+
actionsRef.current.setEditorVisible(true);
|
|
758
|
+
actionsRef.current.setPreviewLocalId(file.localId || null);
|
|
759
|
+
actionsRef.current.setPreviewIsLocalOnly(!!file.isLocalOnly);
|
|
760
|
+
actionsRef.current.setPreviewHasUnsyncedChanges(!!file.hasUnsyncedChanges);
|
|
761
|
+
actionsRef.current.setPreviewFileName(file.fileName);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ---- 服务器文件的普通预览流程 ----
|
|
766
|
+
actionsRef.current.setPreviewFileName(file.fileName);
|
|
767
|
+
actionsRef.current.setPreviewUrl(file.fileResourcePath);
|
|
768
|
+
// 合同链接文件为虚拟文件(无 fileId/缓存语义),crc32 必须置空,
|
|
769
|
+
// 否则预览组件会因"有 crc32 但无 fileId"守卫永久跳过加载
|
|
770
|
+
actionsRef.current.setPreviewCrc32(file.isContractLink ? undefined : file.crc32);
|
|
771
|
+
// 预览时不使用编辑器模式 → 清空本地相关标识
|
|
772
|
+
actionsRef.current.setPreviewLocalId(null);
|
|
773
|
+
actionsRef.current.setPreviewIsLocalOnly(false);
|
|
774
|
+
actionsRef.current.setPreviewHasUnsyncedChanges(false);
|
|
775
|
+
|
|
776
|
+
// 仅当 crc32 实际变化时才更新 files/fileTreeData,避免不必要的新数组引用
|
|
777
|
+
// 触发 index.tsx 的 useEffect(该 effect 依赖 state.files),在 setSearchParams
|
|
778
|
+
// 尚未同步到 currentFileId 时可能用旧 fileid 覆盖刚设置的预览状态,导致首次
|
|
779
|
+
// 打开合同链接文件时出现竞态访问错误。
|
|
780
|
+
const state = stateRef.current;
|
|
781
|
+
const existingFile = state.files.find(f => f.id === file.id);
|
|
782
|
+
if (existingFile && existingFile.crc32 !== file.crc32) {
|
|
783
|
+
const filesWithCrc32 = state.files.map(f =>
|
|
784
|
+
f.id === file.id ? { ...f, crc32: file.crc32 } : f
|
|
785
|
+
);
|
|
786
|
+
actionsRef.current.setFiles(filesWithCrc32);
|
|
787
|
+
actionsRef.current.setFileTreeData(buildFileTree(filesWithCrc32));
|
|
788
|
+
}
|
|
789
|
+
actionsRef.current.setPreviewVisible(true);
|
|
790
|
+
// 同步设置 previewingFile:作为 previewFile 计算的权威来源,
|
|
791
|
+
// 避免依赖 setSearchParams 异步更新的 currentFileId(首次渲染时
|
|
792
|
+
// currentFileId 可能仍为旧值,导致 previewFile 找到错误的文件 → fileId 错误 → 请求被 abort)。
|
|
793
|
+
actionsRef.current.setPreviewingFile(file);
|
|
794
|
+
setSearchParams(prev => {
|
|
795
|
+
const newParams = new URLSearchParams(prev);
|
|
796
|
+
newParams.set('fileid', file.id);
|
|
797
|
+
return newParams;
|
|
798
|
+
}, { replace: true });
|
|
799
|
+
}, [setSearchParams]);
|
|
800
|
+
|
|
801
|
+
const handleDownload = useCallback(async (file: ProjectFile) => {
|
|
802
|
+
// 有 localId 时(本地新建文件 或 服务器文件的未同步修改),优先下载本地编辑的最新版本
|
|
803
|
+
if (file.localId) {
|
|
804
|
+
try {
|
|
805
|
+
const localFile = await getLocalFile(file.localId);
|
|
806
|
+
if (localFile && localFile.content) {
|
|
807
|
+
const content = localFile.content;
|
|
808
|
+
let blob: Blob;
|
|
809
|
+
if (typeof content === 'string') {
|
|
810
|
+
blob = new Blob([content], { type: 'text/markdown' });
|
|
811
|
+
} else {
|
|
812
|
+
blob = new Blob([content], { type: getMimeType(file.fileName) });
|
|
813
|
+
}
|
|
814
|
+
const url = URL.createObjectURL(blob);
|
|
815
|
+
const a = document.createElement('a');
|
|
816
|
+
a.href = url;
|
|
817
|
+
a.download = file.fileName;
|
|
818
|
+
document.body.appendChild(a);
|
|
819
|
+
a.click();
|
|
820
|
+
document.body.removeChild(a);
|
|
821
|
+
URL.revokeObjectURL(url);
|
|
822
|
+
uiToast.success('文件已下载,请查看浏览器的下载文件夹');
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
} catch (err) {
|
|
826
|
+
console.warn('下载本地文件失败,回退到服务器下载:', err);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// 本地新建文件(从未上传)无服务器资源,不尝试服务器下载
|
|
831
|
+
if (file.isLocalOnly) {
|
|
832
|
+
uiToast.error('本地文件内容不可用');
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
uiFilePreview.downloadFile(file.fileResourcePath, file.fileName, file.id, file.crc32)
|
|
837
|
+
.then(() => {
|
|
838
|
+
handleCacheSavedRef.current(file.id);
|
|
839
|
+
})
|
|
840
|
+
.catch(() => {
|
|
841
|
+
// downloadFile 内部已统一提示下载失败,此处仅避免未捕获的 Promise 拒绝
|
|
842
|
+
});
|
|
843
|
+
}, []);
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* 发起文件夹打包下载:后端异步打包,前端轮询任务状态;
|
|
847
|
+
* 打包完成后自动触发下载,若用户已离开页面则由通知中心兜底。
|
|
848
|
+
*/
|
|
849
|
+
const handleDownloadFolder = useCallback(async (file: ProjectFile) => {
|
|
850
|
+
if (file.fileType !== 'folder') return;
|
|
851
|
+
|
|
852
|
+
const downloadFolderApi = api.downloadFolder;
|
|
853
|
+
const statusApi = api.getFolderDownloadStatus;
|
|
854
|
+
if (!downloadFolderApi || !statusApi) {
|
|
855
|
+
uiToast.error('当前场景不支持文件夹下载');
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (!stateRef.current.isManager) {
|
|
859
|
+
uiToast.error('无权下载此文件夹');
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
actionsRef.current.setContextMenuVisible(false);
|
|
864
|
+
actionsRef.current.setContextMenuRecord(null);
|
|
865
|
+
|
|
866
|
+
try {
|
|
867
|
+
const res = await downloadFolderApi(projectId, file.filePath || '/', file.fileName);
|
|
868
|
+
if (res.code !== ResponseCode.SUCCESS || !res.data || !res.data.jobId) {
|
|
869
|
+
uiToast.error(res.message || '发起文件夹打包失败');
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
const jobId = res.data.jobId;
|
|
873
|
+
const zipFileName = `${file.fileName.replace(/[\\/:*?"<>|]/g, '_')}.zip`;
|
|
874
|
+
|
|
875
|
+
uiToast.info(`正在打包文件夹「${file.fileName}」,完成后将自动下载,也可在通知中心查看`);
|
|
876
|
+
|
|
877
|
+
let attempts = 0;
|
|
878
|
+
const timer = setInterval(async () => {
|
|
879
|
+
attempts += 1;
|
|
880
|
+
// 最多轮询约 10 分钟
|
|
881
|
+
if (attempts > 300) {
|
|
882
|
+
clearInterval(timer);
|
|
883
|
+
folderDownloadTimersRef.current.delete(jobId);
|
|
884
|
+
uiToast.info('打包仍在进行,完成后可在通知中心查看下载地址');
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
try {
|
|
889
|
+
const statusRes = await statusApi(jobId);
|
|
890
|
+
if (statusRes.code !== ResponseCode.SUCCESS) {
|
|
891
|
+
clearInterval(timer);
|
|
892
|
+
folderDownloadTimersRef.current.delete(jobId);
|
|
893
|
+
uiToast.error(statusRes.message || '打包任务不存在或已过期');
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (!statusRes.data) return;
|
|
897
|
+
|
|
898
|
+
const job = statusRes.data;
|
|
899
|
+
if (job.status === 'pending') return;
|
|
900
|
+
|
|
901
|
+
clearInterval(timer);
|
|
902
|
+
folderDownloadTimersRef.current.delete(jobId);
|
|
903
|
+
|
|
904
|
+
if (job.status === 'success' && job.url) {
|
|
905
|
+
uiToast.success('文件夹打包完成,即将开始下载');
|
|
906
|
+
uiFilePreview
|
|
907
|
+
.downloadFile(job.url, job.fileName || zipFileName)
|
|
908
|
+
.catch(() => {
|
|
909
|
+
// downloadFile 内部已提示下载失败
|
|
910
|
+
});
|
|
911
|
+
} else {
|
|
912
|
+
uiToast.error(job.message || '文件夹打包失败');
|
|
913
|
+
}
|
|
914
|
+
} catch (err) {
|
|
915
|
+
// 单次轮询失败不中断,等待下一次
|
|
916
|
+
console.warn('查询文件夹打包状态失败:', err);
|
|
917
|
+
}
|
|
918
|
+
}, 2000);
|
|
919
|
+
|
|
920
|
+
folderDownloadTimersRef.current.set(jobId, timer);
|
|
921
|
+
} catch (err) {
|
|
922
|
+
console.error('发起文件夹打包失败:', err);
|
|
923
|
+
uiToast.error('发起文件夹打包失败');
|
|
924
|
+
}
|
|
925
|
+
}, [projectId, api]);
|
|
926
|
+
|
|
927
|
+
const handleShareToWechat = useCallback((file: ProjectFile) => {
|
|
928
|
+
actionsRef.current.setShareToWechatFile(file);
|
|
929
|
+
actionsRef.current.setShareToWechatVisible(true);
|
|
930
|
+
}, []);
|
|
931
|
+
|
|
932
|
+
const handleCopyLink = useCallback(async (file: ProjectFile) => {
|
|
933
|
+
const fileLink = `${window.location.origin}/filepreview?fileid=${file.id}&path=${encodeURIComponent(file.fileResourcePath)}&name=${encodeURIComponent(file.fileName)}`;
|
|
934
|
+
try {
|
|
935
|
+
await copyToClipboard(fileLink);
|
|
936
|
+
message.success('链接已复制到剪贴板');
|
|
937
|
+
} catch (err) {
|
|
938
|
+
// 移动端部分 webview 下 Clipboard API 与 execCommand 均可能失效,
|
|
939
|
+
// 弹出可手动选中复制的输入框作为最终兜底
|
|
940
|
+
showLinkCopyModal(fileLink);
|
|
941
|
+
}
|
|
942
|
+
}, []);
|
|
943
|
+
|
|
944
|
+
const handleJumpToContract = useCallback((file: ProjectFile) => {
|
|
945
|
+
// 合同链接文件 ID 格式: contract-file-{contractId}-{fileName}
|
|
946
|
+
// 合同子文件夹 ID 格式: contract-folder-{contractId}
|
|
947
|
+
// contractId 为 24 位 MongoDB ObjectId
|
|
948
|
+
let contractId: string | undefined;
|
|
949
|
+
if (file.isContractSubfolder) {
|
|
950
|
+
const match = file.id.match(/^contract-folder-([0-9a-f]{24})$/);
|
|
951
|
+
contractId = match?.[1];
|
|
952
|
+
} else {
|
|
953
|
+
const match = file.id.match(/^contract-file-([0-9a-f]{24})-/);
|
|
954
|
+
contractId = match?.[1];
|
|
955
|
+
}
|
|
956
|
+
if (!contractId) {
|
|
957
|
+
message.warning('无法获取合同信息');
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
setSearchParams(prev => {
|
|
961
|
+
const newParams = new URLSearchParams(prev);
|
|
962
|
+
newParams.set('tab', 'contracts');
|
|
963
|
+
newParams.set('contractId', contractId!);
|
|
964
|
+
return newParams;
|
|
965
|
+
}, { replace: true });
|
|
966
|
+
}, [setSearchParams]);
|
|
967
|
+
|
|
968
|
+
const handleCreateContract = useCallback((_file: ProjectFile) => {
|
|
969
|
+
// 切换到合同页签,并通过 URL 参数通知 ContractManager 自动打开新建合同弹窗
|
|
970
|
+
setSearchParams(prev => {
|
|
971
|
+
const newParams = new URLSearchParams(prev);
|
|
972
|
+
newParams.set('tab', 'contracts');
|
|
973
|
+
newParams.set('createContract', '1');
|
|
974
|
+
newParams.delete('contractId');
|
|
975
|
+
return newParams;
|
|
976
|
+
}, { replace: true });
|
|
977
|
+
}, [setSearchParams]);
|
|
978
|
+
|
|
979
|
+
const handleGenerateTempLink = useCallback(async (file: ProjectFile) => {
|
|
980
|
+
try {
|
|
981
|
+
const expireInfo = await new Promise<{ seconds: number; label: string }>((resolve) => {
|
|
982
|
+
const stateRef = { current: 30 };
|
|
983
|
+
const presetOptions = [
|
|
984
|
+
{ days: 7, label: '7天' },
|
|
985
|
+
{ days: 30, label: '30天' },
|
|
986
|
+
{ days: 90, label: '90天' },
|
|
987
|
+
{ days: 365, label: '1年' },
|
|
988
|
+
];
|
|
989
|
+
|
|
990
|
+
const ExpireModalContent = () => {
|
|
991
|
+
const [selectedValue, setSelectedValue] = useState<string>('30');
|
|
992
|
+
|
|
993
|
+
useEffect(() => {
|
|
994
|
+
stateRef.current = parseInt(selectedValue);
|
|
995
|
+
}, [selectedValue]);
|
|
996
|
+
|
|
997
|
+
return React.createElement('div', { className: 'flex flex-col gap-4' }, [
|
|
998
|
+
React.createElement('div', null, '选择有效期'),
|
|
999
|
+
React.createElement('div', { className: 'flex gap-2' },
|
|
1000
|
+
presetOptions.map((option) =>
|
|
1001
|
+
React.createElement(
|
|
1002
|
+
'label',
|
|
1003
|
+
{
|
|
1004
|
+
key: option.days,
|
|
1005
|
+
className: `flex items-center gap-1 px-3 py-2 rounded cursor-pointer border cursor-pointer ${
|
|
1006
|
+
selectedValue === String(option.days)
|
|
1007
|
+
? 'border-blue-500 bg-blue-50'
|
|
1008
|
+
: 'border-gray-300 hover:border-gray-400'
|
|
1009
|
+
}`,
|
|
1010
|
+
onClick: () => setSelectedValue(String(option.days)),
|
|
1011
|
+
},
|
|
1012
|
+
[
|
|
1013
|
+
React.createElement('input', {
|
|
1014
|
+
type: 'radio',
|
|
1015
|
+
name: 'expireTime',
|
|
1016
|
+
value: option.days,
|
|
1017
|
+
checked: selectedValue === String(option.days),
|
|
1018
|
+
onChange: () => setSelectedValue(String(option.days)),
|
|
1019
|
+
className: 'hidden',
|
|
1020
|
+
}),
|
|
1021
|
+
React.createElement('span', null, option.label),
|
|
1022
|
+
]
|
|
1023
|
+
)
|
|
1024
|
+
)
|
|
1025
|
+
)
|
|
1026
|
+
]);
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
const modal = Modal.confirm({
|
|
1030
|
+
className: 'ant-modal-nofull',
|
|
1031
|
+
title: '选择临时链接有效期',
|
|
1032
|
+
content: React.createElement(ExpireModalContent),
|
|
1033
|
+
onOk: () => {
|
|
1034
|
+
const days = stateRef.current;
|
|
1035
|
+
resolve({
|
|
1036
|
+
seconds: days * 24 * 60 * 60,
|
|
1037
|
+
label: presetOptions.find(o => o.days === days)?.label || `${days}天`,
|
|
1038
|
+
});
|
|
1039
|
+
},
|
|
1040
|
+
onCancel: () => {
|
|
1041
|
+
resolve({ seconds: 0, label: '' });
|
|
1042
|
+
},
|
|
1043
|
+
okText: '生成链接',
|
|
1044
|
+
cancelText: '取消',
|
|
1045
|
+
width: 400,
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
return () => modal.destroy();
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
if (!expireInfo.seconds) return;
|
|
1052
|
+
|
|
1053
|
+
if (!api.generateTempAccessToken) return;
|
|
1054
|
+
|
|
1055
|
+
const r = await api.generateTempAccessToken(file.id, expireInfo.seconds);
|
|
1056
|
+
|
|
1057
|
+
if (r.code === ResponseCode.SUCCESS && r.data) {
|
|
1058
|
+
const tempLink = r.data;
|
|
1059
|
+
let shareLink = `${window.location.origin}/filepreview?fileid=${file.id}&path=${encodeURIComponent(file.fileResourcePath)}&name=${encodeURIComponent(file.fileName || '')}&token=${encodeURIComponent(tempLink)}`;
|
|
1060
|
+
if (file.fileType === 'folder') {
|
|
1061
|
+
const folderParams = new URLSearchParams({
|
|
1062
|
+
token: tempLink,
|
|
1063
|
+
folderId: file.id,
|
|
1064
|
+
name: file.fileName || '',
|
|
1065
|
+
path: `${file.filePath}${file.fileName}/`,
|
|
1066
|
+
});
|
|
1067
|
+
shareLink = `${window.location.origin}/fileshare?${folderParams.toString()}`;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
await copyToClipboard(shareLink);
|
|
1071
|
+
message.success(`临时链接已复制到剪贴板(有效期 ${expireInfo.label})`);
|
|
1072
|
+
} else {
|
|
1073
|
+
message.error(r.message || '生成临时链接失败');
|
|
1074
|
+
}
|
|
1075
|
+
} catch (err) {
|
|
1076
|
+
console.error('生成临时链接失败:', err);
|
|
1077
|
+
message.error('生成临时链接失败');
|
|
1078
|
+
}
|
|
1079
|
+
}, [api]);
|
|
1080
|
+
|
|
1081
|
+
const handleDelete = useCallback(async (fileId: string) => {
|
|
1082
|
+
try {
|
|
1083
|
+
const { files } = stateRef.current;
|
|
1084
|
+
const file = files.find((f) => f.id === fileId);
|
|
1085
|
+
|
|
1086
|
+
// 合同附件链接文件、虚拟合同文件夹和合同子文件夹不允许删除
|
|
1087
|
+
if (
|
|
1088
|
+
file?.isContractLink ||
|
|
1089
|
+
file?.isVirtualContractFolder ||
|
|
1090
|
+
file?.isContractSubfolder ||
|
|
1091
|
+
file?.isContractTagFolder
|
|
1092
|
+
) {
|
|
1093
|
+
message.warning('合同附件不支持删除');
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const confirm = await uiConfirm.confirm('确认删除文件吗?');
|
|
1098
|
+
if (!confirm) return;
|
|
1099
|
+
|
|
1100
|
+
// 本地新建文件(isLocalOnly):无需请求服务器,直接从 IndexedDB localFiles store 删除
|
|
1101
|
+
if (file?.isLocalOnly) {
|
|
1102
|
+
if (file.localId) {
|
|
1103
|
+
await deleteLocalFile(file.localId);
|
|
1104
|
+
}
|
|
1105
|
+
// 从文件列表中移除
|
|
1106
|
+
const refreshed = files.filter((f) => f.id !== fileId);
|
|
1107
|
+
actionsRef.current.setFiles(refreshed);
|
|
1108
|
+
actionsRef.current.setFileTreeData(buildFileTree(refreshed));
|
|
1109
|
+
message.success('文件删除成功');
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// 服务器文件:请求服务器删除
|
|
1114
|
+
const r = await api.removeFile(fileId);
|
|
1115
|
+
if (r.code === ResponseCode.SUCCESS) {
|
|
1116
|
+
// 如果该服务器文件有未同步的本地修改,也一并清理
|
|
1117
|
+
if (file?.localId) {
|
|
1118
|
+
try { await deleteLocalFile(file.localId); } catch { /* ignore */ }
|
|
1119
|
+
}
|
|
1120
|
+
message.success('文件删除成功');
|
|
1121
|
+
loadFiles();
|
|
1122
|
+
}
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
message.error('文件删除失败');
|
|
1125
|
+
}
|
|
1126
|
+
}, [api, loadFiles]);
|
|
1127
|
+
|
|
1128
|
+
const isFolderExpanded = useCallback((folderId: string) => {
|
|
1129
|
+
return stateRef.current.expandedRowKeys.includes(folderId);
|
|
1130
|
+
}, []);
|
|
1131
|
+
|
|
1132
|
+
const handleMove = useCallback(async (fileId: string, targetFolderId: string | null) => {
|
|
1133
|
+
const { files } = stateRef.current;
|
|
1134
|
+
const source = files.find((f) => f.id === fileId);
|
|
1135
|
+
if (!source) return;
|
|
1136
|
+
|
|
1137
|
+
// 合同附件链接文件、虚拟合同文件夹和合同子文件夹不允许移动
|
|
1138
|
+
if (
|
|
1139
|
+
source.isContractLink ||
|
|
1140
|
+
source.isVirtualContractFolder ||
|
|
1141
|
+
source.isContractSubfolder ||
|
|
1142
|
+
source.isContractTagFolder
|
|
1143
|
+
) {
|
|
1144
|
+
message.warning('合同附件不支持移动');
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
if (targetFolderId) {
|
|
1149
|
+
if (source.id === targetFolderId) return;
|
|
1150
|
+
const descendantIds = getDescendantIds(source, files);
|
|
1151
|
+
if (descendantIds.includes(targetFolderId)) {
|
|
1152
|
+
message.warning('无法将文件夹移动到其子项中');
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
} else {
|
|
1156
|
+
if (source.filePath === '/') {
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
try {
|
|
1162
|
+
const oldFile = files.find((f) => f.id === fileId);
|
|
1163
|
+
const newFile = files.find((f) => f.id === targetFolderId);
|
|
1164
|
+
|
|
1165
|
+
let newFilePath = '/';
|
|
1166
|
+
if (newFile) {
|
|
1167
|
+
newFilePath = newFile.filePath + newFile.fileName + '/';
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const result = await api.moveFile(projectId, {
|
|
1171
|
+
oldFileName: oldFile?.fileName || '',
|
|
1172
|
+
oldFilePath: oldFile?.filePath || '',
|
|
1173
|
+
newFileName: oldFile?.fileName || '',
|
|
1174
|
+
newFilePath: newFilePath
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
if (result.code === ResponseCode.SUCCESS) {
|
|
1178
|
+
message.success('移动成功');
|
|
1179
|
+
loadFiles();
|
|
1180
|
+
}
|
|
1181
|
+
} catch (error) {
|
|
1182
|
+
message.error('移动失败');
|
|
1183
|
+
}
|
|
1184
|
+
}, [projectId, api, loadFiles]);
|
|
1185
|
+
|
|
1186
|
+
const handleRename = useCallback((file: ProjectFile) => {
|
|
1187
|
+
actionsRef.current.setRenameFileId(file.id);
|
|
1188
|
+
actionsRef.current.setRenameFileName(file.fileName);
|
|
1189
|
+
actionsRef.current.setRenameVisible(true);
|
|
1190
|
+
}, []);
|
|
1191
|
+
|
|
1192
|
+
const handleEditDescs = useCallback((file: ProjectFile) => {
|
|
1193
|
+
actionsRef.current.setDescsFileId(file.id);
|
|
1194
|
+
actionsRef.current.setDescsValue(file.descs || '');
|
|
1195
|
+
actionsRef.current.setDescsVisible(true);
|
|
1196
|
+
actionsRef.current.setContextMenuVisible(false);
|
|
1197
|
+
actionsRef.current.setContextMenuRecord(null);
|
|
1198
|
+
}, []);
|
|
1199
|
+
|
|
1200
|
+
const handleDescsConfirm = useCallback(async (descsFileId: string | null, descsValue: string) => {
|
|
1201
|
+
if (!descsFileId) return;
|
|
1202
|
+
|
|
1203
|
+
try {
|
|
1204
|
+
const result = await api.updateFileDescs({
|
|
1205
|
+
fileId: descsFileId,
|
|
1206
|
+
descs: descsValue.trim().slice(0, FILE_DESCS_MAX_LENGTH),
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
if (result.code === ResponseCode.SUCCESS) {
|
|
1210
|
+
message.success('备注已保存');
|
|
1211
|
+
actionsRef.current.setDescsVisible(false);
|
|
1212
|
+
actionsRef.current.setDescsFileId(null);
|
|
1213
|
+
actionsRef.current.setDescsValue('');
|
|
1214
|
+
loadFiles();
|
|
1215
|
+
} else {
|
|
1216
|
+
message.error(result.message || '备注保存失败');
|
|
1217
|
+
}
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
message.error('备注保存失败');
|
|
1220
|
+
}
|
|
1221
|
+
}, [api, loadFiles]);
|
|
1222
|
+
|
|
1223
|
+
const handleRenameConfirm = useCallback(async (renameFileId: string | null, renameFileName: string) => {
|
|
1224
|
+
const { files } = stateRef.current;
|
|
1225
|
+
const source = files.find((f) => f.id === renameFileId);
|
|
1226
|
+
if (!source) return;
|
|
1227
|
+
|
|
1228
|
+
// 合同附件链接文件、虚拟合同文件夹和合同子文件夹不允许重命名
|
|
1229
|
+
if (
|
|
1230
|
+
source.isContractLink ||
|
|
1231
|
+
source.isVirtualContractFolder ||
|
|
1232
|
+
source.isContractSubfolder ||
|
|
1233
|
+
source.isContractTagFolder
|
|
1234
|
+
) {
|
|
1235
|
+
message.warning('合同附件不支持重命名');
|
|
1236
|
+
actionsRef.current.setRenameVisible(false);
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
let rename = sanitizeFileName(renameFileName);
|
|
1241
|
+
|
|
1242
|
+
// 不允许修改扩展名:文件夹不处理;文件保留原扩展名,丢弃用户输入的扩展名
|
|
1243
|
+
if (source.fileType !== 'folder') {
|
|
1244
|
+
const originalExt = getFileExtension(source.fileName);
|
|
1245
|
+
const userInputExt = getFileExtension(rename);
|
|
1246
|
+
// 用户尝试修改扩展名时提示(大小写差异不视为修改)
|
|
1247
|
+
if (userInputExt.toLowerCase() !== originalExt.toLowerCase()) {
|
|
1248
|
+
message.warning(originalExt
|
|
1249
|
+
? `扩展名不允许修改,已保留原扩展名 ${originalExt}`
|
|
1250
|
+
: '扩展名不允许修改,已去除输入的扩展名');
|
|
1251
|
+
}
|
|
1252
|
+
rename = stripFileExtension(rename) + originalExt;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
if (rename !== renameFileName) {
|
|
1256
|
+
actionsRef.current.setRenameFileName(rename);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
if (source.fileName === rename) return;
|
|
1260
|
+
|
|
1261
|
+
try {
|
|
1262
|
+
const result = await api.moveFile(projectId, {
|
|
1263
|
+
oldFileName: source.fileName,
|
|
1264
|
+
oldFilePath: source.filePath,
|
|
1265
|
+
newFileName: rename,
|
|
1266
|
+
newFilePath: source.filePath,
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
if (result.code === ResponseCode.SUCCESS) {
|
|
1270
|
+
message.success('重命名成功');
|
|
1271
|
+
actionsRef.current.setRenameVisible(false);
|
|
1272
|
+
loadFiles();
|
|
1273
|
+
}
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
message.error('重命名失败');
|
|
1276
|
+
}
|
|
1277
|
+
}, [projectId, api, loadFiles]);
|
|
1278
|
+
|
|
1279
|
+
const handleCreateFolder = useCallback(() => {
|
|
1280
|
+
actionsRef.current.setIsEncryptedFolder(false);
|
|
1281
|
+
actionsRef.current.setCreateFolderVisible(true);
|
|
1282
|
+
actionsRef.current.setNewFolderName('');
|
|
1283
|
+
actionsRef.current.setNewFolderDescs('');
|
|
1284
|
+
}, []);
|
|
1285
|
+
|
|
1286
|
+
/** 通用:在 IndexedDB 中创建本地空白的"新建文件"记录(未上传),然后直接在编辑器中打开 */
|
|
1287
|
+
const createLocalOnlyAndOpen = useCallback(async (fileName: string, fileType: 'pptx' | 'xlsx' | 'md' | 'markdown' | 'docx' | 'rdoc') => {
|
|
1288
|
+
try {
|
|
1289
|
+
const userInfo = await CacheUserInfo.getUserInfo({ forceRefresh: false });
|
|
1290
|
+
const userId = userInfo?.id || 'local-user';
|
|
1291
|
+
const { selectedFolderId } = stateRef.current;
|
|
1292
|
+
// 创建本地记录:传空 content(编辑器会生成空白文档)
|
|
1293
|
+
const createdRecord = await createLocalOnlyFile({
|
|
1294
|
+
userId,
|
|
1295
|
+
projectId,
|
|
1296
|
+
parentId: selectedFolderId,
|
|
1297
|
+
fileName,
|
|
1298
|
+
fileType,
|
|
1299
|
+
content: '',
|
|
1300
|
+
});
|
|
1301
|
+
const newPf = localFileRecordToProjectFile(createdRecord);
|
|
1302
|
+
|
|
1303
|
+
// 立即追加到当前文件列表(无需等待 reload)
|
|
1304
|
+
const currentFiles = stateRef.current.files;
|
|
1305
|
+
const newFiles = sortFiles([...currentFiles, newPf]);
|
|
1306
|
+
actionsRef.current.setFiles(newFiles);
|
|
1307
|
+
actionsRef.current.setFileTreeData(buildFileTree(newFiles));
|
|
1308
|
+
|
|
1309
|
+
// 打开编辑器(编辑本地文件)
|
|
1310
|
+
actionsRef.current.setEditingFile(newPf);
|
|
1311
|
+
actionsRef.current.setEditorVisible(true);
|
|
1312
|
+
actionsRef.current.setPreviewLocalId(createdRecord.localId);
|
|
1313
|
+
actionsRef.current.setPreviewIsLocalOnly(true);
|
|
1314
|
+
actionsRef.current.setPreviewHasUnsyncedChanges(createdRecord.hasUnsyncedChanges);
|
|
1315
|
+
actionsRef.current.setPreviewFileName(fileName);
|
|
1316
|
+
} catch (err) {
|
|
1317
|
+
console.error('创建本地文件失败:', err);
|
|
1318
|
+
message.error('创建本地文件失败');
|
|
1319
|
+
}
|
|
1320
|
+
}, [projectId]);
|
|
1321
|
+
|
|
1322
|
+
const openCreator = useCallback((fileName: string, fileType: CreatingFileType) => {
|
|
1323
|
+
// 旧逻辑保留兼容(部分特殊入口仍可能使用)
|
|
1324
|
+
actionsRef.current.setCreatingFileName(fileName);
|
|
1325
|
+
actionsRef.current.setCreatingFileType(fileType);
|
|
1326
|
+
actionsRef.current.setCreatorVisible(true);
|
|
1327
|
+
}, []);
|
|
1328
|
+
|
|
1329
|
+
/** 打开新建文件名输入对话框,预填默认文件名 */
|
|
1330
|
+
const openCreateFileDialog = useCallback((defaultFileName: string, fileType: CreatingFileType) => {
|
|
1331
|
+
actionsRef.current.setNewFileName(defaultFileName);
|
|
1332
|
+
actionsRef.current.setPendingFileType(fileType);
|
|
1333
|
+
actionsRef.current.setCreateFileVisible(true);
|
|
1334
|
+
}, []);
|
|
1335
|
+
|
|
1336
|
+
/** 新建文件名对话框确认:校验文件名并保留对应扩展名后创建本地文件并打开编辑器 */
|
|
1337
|
+
const handleCreateFileConfirm = useCallback((fileName: string, fileType: CreatingFileType) => {
|
|
1338
|
+
if (!fileType) return;
|
|
1339
|
+
let name = sanitizeFileName(fileName);
|
|
1340
|
+
if (!name) {
|
|
1341
|
+
message.warning('请输入文件名称');
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
// 根据待创建类型确定目标扩展名,保留对应扩展名,丢弃用户输入的扩展名(与重命名一致)
|
|
1345
|
+
const targetExt = CREATE_FILE_EXT_MAP[fileType];
|
|
1346
|
+
const userInputExt = getFileExtension(name);
|
|
1347
|
+
if (userInputExt.toLowerCase() !== targetExt.toLowerCase()) {
|
|
1348
|
+
name = stripFileExtension(name) + targetExt;
|
|
1349
|
+
actionsRef.current.setNewFileName(name);
|
|
1350
|
+
}
|
|
1351
|
+
actionsRef.current.setCreateFileVisible(false);
|
|
1352
|
+
createLocalOnlyAndOpen(name, fileType);
|
|
1353
|
+
}, [createLocalOnlyAndOpen]);
|
|
1354
|
+
|
|
1355
|
+
const handleCreatePptx = useCallback(() => {
|
|
1356
|
+
openCreateFileDialog('新建演示文稿.pptx', 'pptx');
|
|
1357
|
+
}, [openCreateFileDialog]);
|
|
1358
|
+
|
|
1359
|
+
const handleCreateMarkdown = useCallback(() => {
|
|
1360
|
+
openCreateFileDialog('新建文档.md', 'md');
|
|
1361
|
+
}, [openCreateFileDialog]);
|
|
1362
|
+
|
|
1363
|
+
const handleCreateExcel = useCallback(() => {
|
|
1364
|
+
openCreateFileDialog('新建表格.xlsx', 'xlsx');
|
|
1365
|
+
}, [openCreateFileDialog]);
|
|
1366
|
+
|
|
1367
|
+
const handleCreateWord = useCallback(() => {
|
|
1368
|
+
openCreateFileDialog('新建文档.docx', 'docx');
|
|
1369
|
+
}, [openCreateFileDialog]);
|
|
1370
|
+
|
|
1371
|
+
const handleCreateRdoc = useCallback(() => {
|
|
1372
|
+
openCreateFileDialog('新建文档.rdoc', 'rdoc');
|
|
1373
|
+
}, [openCreateFileDialog]);
|
|
1374
|
+
|
|
1375
|
+
const handleCreateFolderConfirm = useCallback(async (newFolderName: string, newFolderDescs: string, isEncryptedFolder: boolean) => {
|
|
1376
|
+
if (!newFolderName.trim()) {
|
|
1377
|
+
message.warning('请输入文件夹名称');
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
const folderName = sanitizeFileName(newFolderName);
|
|
1382
|
+
if (folderName !== newFolderName) {
|
|
1383
|
+
actionsRef.current.setNewFolderName(folderName);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
try {
|
|
1387
|
+
const { files, selectedFolderId } = stateRef.current;
|
|
1388
|
+
const selectedFolder = files.find((f) => f.id === selectedFolderId);
|
|
1389
|
+
|
|
1390
|
+
await api.addFile(projectId, {
|
|
1391
|
+
fileName: folderName,
|
|
1392
|
+
filePath: selectedFolder ? `${selectedFolder.filePath}${selectedFolder.fileName}/` : `/`,
|
|
1393
|
+
fileType: 'folder',
|
|
1394
|
+
fileSize: 0,
|
|
1395
|
+
fileResourcePath: '',
|
|
1396
|
+
isSecure: isEncryptedFolder,
|
|
1397
|
+
descs: newFolderDescs.trim().slice(0, FILE_DESCS_MAX_LENGTH),
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
loadFiles();
|
|
1401
|
+
actionsRef.current.setCreateFolderVisible(false);
|
|
1402
|
+
} catch (error) {
|
|
1403
|
+
message.error('文件夹创建失败');
|
|
1404
|
+
}
|
|
1405
|
+
}, [projectId, api, loadFiles]);
|
|
1406
|
+
|
|
1407
|
+
const handleEditFile = useCallback(async (file: ProjectFile) => {
|
|
1408
|
+
const ext = file.fileName.toLowerCase().split('.').pop() || '';
|
|
1409
|
+
if (!['md', 'xlsx', 'docx', 'rdoc'].includes(ext)) {
|
|
1410
|
+
message.error('不支持编辑该文件类型');
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const isOnlineFlag = typeof navigator !== 'undefined' ? navigator.onLine : true;
|
|
1415
|
+
|
|
1416
|
+
// ---- 本地文件:直接在编辑器中打开(已有 localId)----
|
|
1417
|
+
if (file.localId) {
|
|
1418
|
+
actionsRef.current.setEditingFile(file);
|
|
1419
|
+
actionsRef.current.setEditorVisible(true);
|
|
1420
|
+
actionsRef.current.setPreviewLocalId(file.localId);
|
|
1421
|
+
actionsRef.current.setPreviewIsLocalOnly(!!file.isLocalOnly);
|
|
1422
|
+
actionsRef.current.setPreviewHasUnsyncedChanges(!!file.hasUnsyncedChanges);
|
|
1423
|
+
actionsRef.current.setPreviewFileName(file.fileName);
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// ---- 服务器文件:离线时先在 localFiles 创建"未同步修改"的快照条目 ----
|
|
1428
|
+
if (!isOnlineFlag) {
|
|
1429
|
+
try {
|
|
1430
|
+
const userInfo = await CacheUserInfo.getUserInfo({ forceRefresh: false });
|
|
1431
|
+
const userId = userInfo?.id || 'local-user';
|
|
1432
|
+
// 读取现有缓存(若存在)作为初始内容
|
|
1433
|
+
let initialContent: ArrayBuffer | string = '';
|
|
1434
|
+
const cacheRec = await CacheFileContent.getCachedContent(file.id);
|
|
1435
|
+
if (cacheRec) initialContent = await cacheRec.content.arrayBuffer();
|
|
1436
|
+
|
|
1437
|
+
// 若此文件已经有本地未同步记录,使用那个 localId
|
|
1438
|
+
const existing = await getLocalFileByServerId(userId, file.id);
|
|
1439
|
+
const rec = existing ?? await saveUnsyncedServerFile({
|
|
1440
|
+
userId,
|
|
1441
|
+
projectId,
|
|
1442
|
+
parentId: file.parentId ?? null,
|
|
1443
|
+
fileName: file.fileName,
|
|
1444
|
+
fileType: (ext === 'docx' ? 'docx'
|
|
1445
|
+
: ext === 'xlsx' ? 'xlsx'
|
|
1446
|
+
: ext === 'pptx' ? 'pptx'
|
|
1447
|
+
: ext === 'md' ? 'md' : 'markdown') as any,
|
|
1448
|
+
serverFileId: file.id,
|
|
1449
|
+
originalServerFile: file,
|
|
1450
|
+
content: initialContent,
|
|
1451
|
+
});
|
|
1452
|
+
// 打开编辑器(使用 localFiles 中的内容)
|
|
1453
|
+
actionsRef.current.setEditingFile({
|
|
1454
|
+
...file,
|
|
1455
|
+
localId: rec.localId,
|
|
1456
|
+
isLocalOnly: false,
|
|
1457
|
+
hasUnsyncedChanges: true,
|
|
1458
|
+
});
|
|
1459
|
+
actionsRef.current.setEditorVisible(true);
|
|
1460
|
+
actionsRef.current.setPreviewLocalId(rec.localId);
|
|
1461
|
+
actionsRef.current.setPreviewIsLocalOnly(false);
|
|
1462
|
+
actionsRef.current.setPreviewHasUnsyncedChanges(true);
|
|
1463
|
+
actionsRef.current.setPreviewFileName(file.fileName);
|
|
1464
|
+
// 列表中立即标记
|
|
1465
|
+
const updated = stateRef.current.files.map(f =>
|
|
1466
|
+
f.id === file.id ? { ...f, localId: rec.localId, hasUnsyncedChanges: true } : f
|
|
1467
|
+
);
|
|
1468
|
+
actionsRef.current.setFiles(updated);
|
|
1469
|
+
actionsRef.current.setFileTreeData(buildFileTree(updated));
|
|
1470
|
+
return;
|
|
1471
|
+
} catch (err) {
|
|
1472
|
+
console.warn('创建离线编辑快照失败:', err);
|
|
1473
|
+
// 降级:继续按照普通在线流程打开(无法上传)
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// ---- 服务器文件(在线):不提前创建 localFiles 记录 ----
|
|
1478
|
+
// fetchFileBuffer 内部会检查 CacheFileContent 缓存,上传后的文件可直接从缓存加载。
|
|
1479
|
+
// 仅在用户保存本地缓存时(handleSaveLocalBlob/handleSaveLocalMarkdown)才创建 localFiles 记录。
|
|
1480
|
+
actionsRef.current.setEditingFile(file);
|
|
1481
|
+
actionsRef.current.setEditorVisible(true);
|
|
1482
|
+
actionsRef.current.setPreviewLocalId(file.localId || null);
|
|
1483
|
+
actionsRef.current.setPreviewIsLocalOnly(false);
|
|
1484
|
+
actionsRef.current.setPreviewHasUnsyncedChanges(!!file.hasUnsyncedChanges);
|
|
1485
|
+
actionsRef.current.setPreviewFileName(file.fileName);
|
|
1486
|
+
actionsRef.current.setPreviewCrc32(file.crc32);
|
|
1487
|
+
}, [projectId]);
|
|
1488
|
+
|
|
1489
|
+
// ---- 编辑器保存到本地缓存时调用(在 IndexedDB localFiles store 中同步更新内容和 hasUnsyncedChanges)----
|
|
1490
|
+
const handleSaveLocalBlob = useCallback(async (localId: string | null | undefined, blob: Blob, fileName: string, markUnsynced: boolean = true) => {
|
|
1491
|
+
try {
|
|
1492
|
+
const buf = await blob.arrayBuffer();
|
|
1493
|
+
let newLocalId: string | null | undefined = localId;
|
|
1494
|
+
// 实际 stateRef 里包含 editingFile,本文件局部 FileManagerState 类型未声明,使用 any 绕过
|
|
1495
|
+
const st = stateRef.current as any;
|
|
1496
|
+
|
|
1497
|
+
// localId 为空(例如上传成功后清除了,继续编辑首次保存)→ 在 localFiles 中新建一条记录
|
|
1498
|
+
if (!newLocalId) {
|
|
1499
|
+
const editingFile = st.editingFile as ProjectFile | null;
|
|
1500
|
+
// 只处理服务器文件的未同步修改(本地新建文件由其他流程处理)
|
|
1501
|
+
if (!editingFile || editingFile.isLocalOnly || !editingFile.id) return;
|
|
1502
|
+
|
|
1503
|
+
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
|
1504
|
+
const userInfo = await CacheUserInfo.getUserInfo({ forceRefresh: false });
|
|
1505
|
+
const userId = userInfo?.id || 'local-user';
|
|
1506
|
+
|
|
1507
|
+
// 从 CacheFileContent 获取原始服务器内容,用于后续差分上传基准
|
|
1508
|
+
let originalContent: ArrayBuffer | undefined;
|
|
1509
|
+
try {
|
|
1510
|
+
const cached = await CacheFileContent.getCachedContent(editingFile.id);
|
|
1511
|
+
if (cached) {
|
|
1512
|
+
originalContent = await cached.content.arrayBuffer();
|
|
1513
|
+
}
|
|
1514
|
+
} catch (err) { console.warn('获取原始服务器缓存失败:', err); }
|
|
1515
|
+
|
|
1516
|
+
const rec = await saveUnsyncedServerFile({
|
|
1517
|
+
userId,
|
|
1518
|
+
projectId,
|
|
1519
|
+
parentId: editingFile.parentId ?? null,
|
|
1520
|
+
fileName,
|
|
1521
|
+
fileType: (ext === 'docx' ? 'docx'
|
|
1522
|
+
: ext === 'xlsx' ? 'xlsx'
|
|
1523
|
+
: ext === 'pptx' ? 'pptx'
|
|
1524
|
+
: ext === 'md' ? 'md' : 'markdown') as any,
|
|
1525
|
+
serverFileId: editingFile.id,
|
|
1526
|
+
originalServerFile: editingFile,
|
|
1527
|
+
content: buf,
|
|
1528
|
+
originalContent,
|
|
1529
|
+
markUnsynced,
|
|
1530
|
+
});
|
|
1531
|
+
newLocalId = rec.localId;
|
|
1532
|
+
} else {
|
|
1533
|
+
// 已有 localId → 更新内容。
|
|
1534
|
+
// 兜底:如果记录已被删除(例如上传后 deleteLocalFile 但 localId 未重置),
|
|
1535
|
+
// updateLocalFileContent 返回 null,此时需要新建一条记录。
|
|
1536
|
+
const updated = await updateLocalFileContent(newLocalId, buf, fileName, markUnsynced);
|
|
1537
|
+
if (!updated) {
|
|
1538
|
+
const editingFile = st.editingFile as ProjectFile | null;
|
|
1539
|
+
if (!editingFile || editingFile.isLocalOnly || !editingFile.id) return;
|
|
1540
|
+
|
|
1541
|
+
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
|
1542
|
+
const userInfo = await CacheUserInfo.getUserInfo({ forceRefresh: false });
|
|
1543
|
+
const userId = userInfo?.id || 'local-user';
|
|
1544
|
+
|
|
1545
|
+
const rec = await saveUnsyncedServerFile({
|
|
1546
|
+
userId,
|
|
1547
|
+
projectId,
|
|
1548
|
+
parentId: editingFile.parentId ?? null,
|
|
1549
|
+
fileName,
|
|
1550
|
+
fileType: (ext === 'docx' ? 'docx'
|
|
1551
|
+
: ext === 'xlsx' ? 'xlsx'
|
|
1552
|
+
: ext === 'pptx' ? 'pptx'
|
|
1553
|
+
: ext === 'md' ? 'md' : 'markdown') as any,
|
|
1554
|
+
serverFileId: editingFile.id,
|
|
1555
|
+
originalServerFile: editingFile,
|
|
1556
|
+
content: buf,
|
|
1557
|
+
markUnsynced,
|
|
1558
|
+
});
|
|
1559
|
+
newLocalId = rec.localId;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// 同步刷新列表中的 hasUnsyncedChanges 标记、localId 和 fileSize
|
|
1564
|
+
// markUnsynced=false(自动保存/手动保存)时不修改 hasUnsyncedChanges,只更新内容和 localId
|
|
1565
|
+
const refreshed = stateRef.current.files.map(f => {
|
|
1566
|
+
if (st.editingFile && f.id === (st.editingFile as ProjectFile).id) {
|
|
1567
|
+
return { ...f, hasUnsyncedChanges: markUnsynced ? true : f.hasUnsyncedChanges, localId: newLocalId, fileSize: buf.byteLength };
|
|
1568
|
+
}
|
|
1569
|
+
if (f.localId === newLocalId) {
|
|
1570
|
+
return { ...f, hasUnsyncedChanges: markUnsynced ? true : f.hasUnsyncedChanges, fileSize: buf.byteLength };
|
|
1571
|
+
}
|
|
1572
|
+
return f;
|
|
1573
|
+
});
|
|
1574
|
+
actionsRef.current.setFiles(refreshed);
|
|
1575
|
+
actionsRef.current.setFileTreeData(buildFileTree(refreshed));
|
|
1576
|
+
|
|
1577
|
+
// 同步更新预览和编辑器的未同步状态
|
|
1578
|
+
if (markUnsynced) {
|
|
1579
|
+
actionsRef.current.setPreviewHasUnsyncedChanges(true);
|
|
1580
|
+
}
|
|
1581
|
+
if (newLocalId) {
|
|
1582
|
+
actionsRef.current.setPreviewLocalId(newLocalId);
|
|
1583
|
+
}
|
|
1584
|
+
if (st.editingFile) {
|
|
1585
|
+
actionsRef.current.setEditingFile({
|
|
1586
|
+
...st.editingFile,
|
|
1587
|
+
hasUnsyncedChanges: markUnsynced ? true : (st.editingFile as ProjectFile).hasUnsyncedChanges,
|
|
1588
|
+
localId: newLocalId ?? st.editingFile.localId,
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
} catch (err) {
|
|
1592
|
+
console.error('保存本地文件失败:', err);
|
|
1593
|
+
}
|
|
1594
|
+
}, [projectId]);
|
|
1595
|
+
|
|
1596
|
+
const handleSaveLocalMarkdown = useCallback(async (localId: string | null | undefined, content: string, fileName: string, markUnsynced: boolean = true) => {
|
|
1597
|
+
try {
|
|
1598
|
+
let newLocalId: string | null | undefined = localId;
|
|
1599
|
+
const st = stateRef.current as any;
|
|
1600
|
+
|
|
1601
|
+
if (!newLocalId) {
|
|
1602
|
+
const editingFile = st.editingFile as ProjectFile | null;
|
|
1603
|
+
if (!editingFile || editingFile.isLocalOnly || !editingFile.id) return;
|
|
1604
|
+
|
|
1605
|
+
const userInfo = await CacheUserInfo.getUserInfo({ forceRefresh: false });
|
|
1606
|
+
const userId = userInfo?.id || 'local-user';
|
|
1607
|
+
|
|
1608
|
+
// 从 CacheFileContent 获取原始服务器内容,用于后续差分上传基准
|
|
1609
|
+
let originalContent: string | undefined;
|
|
1610
|
+
try {
|
|
1611
|
+
const cached = await CacheFileContent.getCachedContent(editingFile.id);
|
|
1612
|
+
if (cached) {
|
|
1613
|
+
originalContent = await cached.content.text();
|
|
1614
|
+
}
|
|
1615
|
+
} catch (err) { console.warn('获取原始服务器缓存失败:', err); }
|
|
1616
|
+
|
|
1617
|
+
const rec = await saveUnsyncedServerFile({
|
|
1618
|
+
userId,
|
|
1619
|
+
projectId,
|
|
1620
|
+
parentId: editingFile.parentId ?? null,
|
|
1621
|
+
fileName,
|
|
1622
|
+
fileType: 'md',
|
|
1623
|
+
serverFileId: editingFile.id,
|
|
1624
|
+
originalServerFile: editingFile,
|
|
1625
|
+
content,
|
|
1626
|
+
originalContent,
|
|
1627
|
+
markUnsynced,
|
|
1628
|
+
});
|
|
1629
|
+
newLocalId = rec.localId;
|
|
1630
|
+
} else {
|
|
1631
|
+
// 兜底:如果记录已被删除,updateLocalFileContent 返回 null,新建一条
|
|
1632
|
+
const updated = await updateLocalFileContent(newLocalId, content, fileName, markUnsynced);
|
|
1633
|
+
if (!updated) {
|
|
1634
|
+
const editingFile = st.editingFile as ProjectFile | null;
|
|
1635
|
+
if (!editingFile || editingFile.isLocalOnly || !editingFile.id) return;
|
|
1636
|
+
|
|
1637
|
+
const userInfo = await CacheUserInfo.getUserInfo({ forceRefresh: false });
|
|
1638
|
+
const userId = userInfo?.id || 'local-user';
|
|
1639
|
+
|
|
1640
|
+
const rec = await saveUnsyncedServerFile({
|
|
1641
|
+
userId,
|
|
1642
|
+
projectId,
|
|
1643
|
+
parentId: editingFile.parentId ?? null,
|
|
1644
|
+
fileName,
|
|
1645
|
+
fileType: 'md',
|
|
1646
|
+
serverFileId: editingFile.id,
|
|
1647
|
+
originalServerFile: editingFile,
|
|
1648
|
+
content,
|
|
1649
|
+
markUnsynced,
|
|
1650
|
+
});
|
|
1651
|
+
newLocalId = rec.localId;
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
const refreshed = stateRef.current.files.map(f => {
|
|
1656
|
+
if (st.editingFile && f.id === (st.editingFile as ProjectFile).id) {
|
|
1657
|
+
return { ...f, hasUnsyncedChanges: markUnsynced ? true : f.hasUnsyncedChanges, localId: newLocalId, fileSize: content.length };
|
|
1658
|
+
}
|
|
1659
|
+
if (f.localId === newLocalId) {
|
|
1660
|
+
return { ...f, hasUnsyncedChanges: markUnsynced ? true : f.hasUnsyncedChanges, fileSize: content.length };
|
|
1661
|
+
}
|
|
1662
|
+
return f;
|
|
1663
|
+
});
|
|
1664
|
+
actionsRef.current.setFiles(refreshed);
|
|
1665
|
+
actionsRef.current.setFileTreeData(buildFileTree(refreshed));
|
|
1666
|
+
|
|
1667
|
+
if (markUnsynced) {
|
|
1668
|
+
actionsRef.current.setPreviewHasUnsyncedChanges(true);
|
|
1669
|
+
}
|
|
1670
|
+
if (newLocalId) {
|
|
1671
|
+
actionsRef.current.setPreviewLocalId(newLocalId);
|
|
1672
|
+
}
|
|
1673
|
+
if (st.editingFile) {
|
|
1674
|
+
actionsRef.current.setEditingFile({
|
|
1675
|
+
...st.editingFile,
|
|
1676
|
+
hasUnsyncedChanges: markUnsynced ? true : (st.editingFile as ProjectFile).hasUnsyncedChanges,
|
|
1677
|
+
localId: newLocalId ?? st.editingFile.localId,
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
} catch (err) {
|
|
1681
|
+
console.error('保存本地 Markdown 失败:', err);
|
|
1682
|
+
}
|
|
1683
|
+
}, [projectId]);
|
|
1684
|
+
|
|
1685
|
+
const handleUploadBlob = useCallback(async (editingFile: ProjectFile | null, initialBlob: Blob | undefined, blob: Blob, fileName: string) => {
|
|
1686
|
+
if (!editingFile) return;
|
|
1687
|
+
try {
|
|
1688
|
+
const isLocalOnly = !!editingFile.isLocalOnly;
|
|
1689
|
+
const localId = editingFile.localId;
|
|
1690
|
+
let serverFileIdForCache = editingFile.id; // 服务器文件 id(新建后会替换)
|
|
1691
|
+
|
|
1692
|
+
if (isLocalOnly) {
|
|
1693
|
+
// ========== 本地新建文件(首次上传)→ CREATE 流程:上传文件 + addFile 到服务器 ==========
|
|
1694
|
+
const { files, selectedFolderId } = stateRef.current;
|
|
1695
|
+
const selectedFolder = files.find((f) => f.id === selectedFolderId);
|
|
1696
|
+
const filePath = selectedFolder ? `${selectedFolder.filePath}${selectedFolder.fileName}/` : '/';
|
|
1697
|
+
|
|
1698
|
+
const { crc32, serverFileName } = await uploadFileBlob(blob, fileName,
|
|
1699
|
+
percent => actionsRef.current.setUploadPercent(percent),
|
|
1700
|
+
success => actionsRef.current.setUploadSuccess(success),
|
|
1701
|
+
);
|
|
1702
|
+
|
|
1703
|
+
const response = await api.addFile(projectId, {
|
|
1704
|
+
fileName,
|
|
1705
|
+
filePath,
|
|
1706
|
+
fileType: getMimeType(fileName),
|
|
1707
|
+
fileSize: blob.size,
|
|
1708
|
+
crc32,
|
|
1709
|
+
fileResourcePath: serverFileName,
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
if (response.code === ResponseCode.SUCCESS && response.data) {
|
|
1713
|
+
serverFileIdForCache = response.data.fileId;
|
|
1714
|
+
// 标记本地记录为已同步(删除本地条目,因为服务器已经成为权威来源)
|
|
1715
|
+
if (localId) {
|
|
1716
|
+
try {
|
|
1717
|
+
await markLocalFileSynced(localId, serverFileIdForCache);
|
|
1718
|
+
await deleteLocalFile(localId);
|
|
1719
|
+
} catch (err) { console.warn('标记本地已同步失败:', err); }
|
|
1720
|
+
}
|
|
1721
|
+
// 保存服务器缓存
|
|
1722
|
+
await CacheFileContent.saveToCache(serverFileIdForCache, blob, crc32, blob.size);
|
|
1723
|
+
actionsRef.current.setUploadPercent(undefined);
|
|
1724
|
+
message.success('文件上传成功');
|
|
1725
|
+
loadFiles();
|
|
1726
|
+
} else {
|
|
1727
|
+
actionsRef.current.setUploadPercent(undefined);
|
|
1728
|
+
message.error(response.message || '上传失败');
|
|
1729
|
+
}
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// ========== 服务器文件的修改 → UPDATE 流程 ==========
|
|
1734
|
+
// 上传前检查文件是否被其他用户更新过(基于 uploadTime 对比)
|
|
1735
|
+
const conflictInfo = await checkFileUpdateConflict(api, editingFile);
|
|
1736
|
+
if (conflictInfo.result === 'cancel') {
|
|
1737
|
+
actionsRef.current.setUploadPercent(undefined);
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// 差分基准选择:
|
|
1742
|
+
// - 有 localId(未同步修改):优先使用 CacheFileContent 中经过 CRC 校验的服务器内容,
|
|
1743
|
+
// 其次使用本地记录中保存的 originalContent,
|
|
1744
|
+
// 最后回退到 initialBlob(可能是编辑后的内容,差分可能失败)
|
|
1745
|
+
// - 无 localId(在线编辑):使用 initialBlob 作为差分基准
|
|
1746
|
+
let deltaBaseBlob = initialBlob;
|
|
1747
|
+
if (localId && !conflictInfo.result.includes('overwrite')) {
|
|
1748
|
+
// 优先:从 CacheFileContent 获取并 CRC 校验
|
|
1749
|
+
try {
|
|
1750
|
+
const cached = await CacheFileContent.getCachedContent(editingFile.id);
|
|
1751
|
+
if (cached && conflictInfo.serverCrc32 !== undefined) {
|
|
1752
|
+
const cachedCrc = await calculateCRC32(new File([cached.content], fileName, { type: getMimeType(fileName) }));
|
|
1753
|
+
if (cachedCrc === conflictInfo.serverCrc32) {
|
|
1754
|
+
const buf = await cached.content.arrayBuffer();
|
|
1755
|
+
deltaBaseBlob = new Blob([buf]);
|
|
1756
|
+
} else {
|
|
1757
|
+
console.warn('[上传诊断] CacheFileContent CRC 不匹配服务器, cached:', cachedCrc, 'server:', conflictInfo.serverCrc32);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
} catch (err) {
|
|
1761
|
+
console.warn('[上传诊断] CacheFileContent 获取失败:', err);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
// 其次:从本地记录的 originalContent 获取
|
|
1765
|
+
if (deltaBaseBlob === initialBlob) {
|
|
1766
|
+
try {
|
|
1767
|
+
const localRec = await getLocalFile(localId);
|
|
1768
|
+
if (localRec?.originalContent) {
|
|
1769
|
+
const origBuf = typeof localRec.originalContent === 'string'
|
|
1770
|
+
? new TextEncoder().encode(localRec.originalContent).buffer
|
|
1771
|
+
: localRec.originalContent;
|
|
1772
|
+
deltaBaseBlob = new Blob([origBuf]);
|
|
1773
|
+
}
|
|
1774
|
+
} catch (err) {
|
|
1775
|
+
console.warn('[上传诊断] 加载 originalContent 失败:', err);
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// 冲突覆盖 → 强制全量上传;无冲突 → 先尝试差分上传
|
|
1781
|
+
// deltaBaseBlob / initialBlob 为空时无法做差分,走全量
|
|
1782
|
+
const forceFullUpload = conflictInfo.result === 'overwrite' || !deltaBaseBlob;
|
|
1783
|
+
|
|
1784
|
+
// 差分失败时的确认回调:有 localId 时提示"其他用户已上传新版本",无 localId 时自动回退
|
|
1785
|
+
const onDeltaFail = localId ? async () => {
|
|
1786
|
+
const confirmed = await uiConfirm.confirm('已经有其他用户更新了文件,是否全量覆盖上传?');
|
|
1787
|
+
return confirmed;
|
|
1788
|
+
} : undefined;
|
|
1789
|
+
|
|
1790
|
+
const { crc32 } = await uploadFileDelta(
|
|
1791
|
+
editingFile.id,
|
|
1792
|
+
fileName,
|
|
1793
|
+
deltaBaseBlob || new Blob([]),
|
|
1794
|
+
blob,
|
|
1795
|
+
percent => actionsRef.current.setUploadPercent(percent),
|
|
1796
|
+
success => actionsRef.current.setUploadSuccess(success),
|
|
1797
|
+
forceFullUpload,
|
|
1798
|
+
onDeltaFail,
|
|
1799
|
+
{ updateFile: api.updateFile, updateFileDelta: api.updateFileDelta },
|
|
1800
|
+
);
|
|
1801
|
+
|
|
1802
|
+
// 更新服务器缓存
|
|
1803
|
+
await CacheFileContent.removeFromCache(editingFile.id);
|
|
1804
|
+
await CacheFileContent.saveToCache(editingFile.id, blob, crc32, blob.size);
|
|
1805
|
+
|
|
1806
|
+
// 若之前是未同步修改的服务器文件 → 标记同步完成
|
|
1807
|
+
if (localId) {
|
|
1808
|
+
try {
|
|
1809
|
+
await markLocalFileSynced(localId, editingFile.id);
|
|
1810
|
+
await deleteLocalFile(localId);
|
|
1811
|
+
} catch (err) { console.warn('标记未同步文件为已同步失败:', err); }
|
|
1812
|
+
// 刷新本地标记
|
|
1813
|
+
const refreshed = stateRef.current.files.map(f =>
|
|
1814
|
+
f.id === editingFile.id ? { ...f, hasUnsyncedChanges: false, localId: undefined } : f
|
|
1815
|
+
);
|
|
1816
|
+
actionsRef.current.setFiles(refreshed);
|
|
1817
|
+
actionsRef.current.setFileTreeData(buildFileTree(refreshed));
|
|
1818
|
+
}
|
|
1819
|
+
// 清除编辑器中的"有未同步修改"标记
|
|
1820
|
+
actionsRef.current.setPreviewHasUnsyncedChanges(false);
|
|
1821
|
+
// 重置 previewLocalId:上传成功后旧的 localFiles 记录已被删除,
|
|
1822
|
+
// 若不重置,后续编辑保存时 effectiveLocalId 仍指向已删除的 localId,
|
|
1823
|
+
// 导致 updateLocalFileContent 找不到记录、内容不持久化,再次打开时出现 "No document loaded"。
|
|
1824
|
+
actionsRef.current.setPreviewLocalId(null);
|
|
1825
|
+
// 同步更新 editingFile,确保编辑器 props 中的 hasUnsyncedChanges 立即生效
|
|
1826
|
+
// 同时刷新 uploadTime,避免下次冲突检测把自己刚上传的误判为"他人更新"
|
|
1827
|
+
const latestMeta = await fetchLatestFileMeta(api, editingFile.id);
|
|
1828
|
+
actionsRef.current.setEditingFile({
|
|
1829
|
+
...editingFile,
|
|
1830
|
+
hasUnsyncedChanges: false,
|
|
1831
|
+
localId: undefined,
|
|
1832
|
+
uploadTime: latestMeta?.uploadTime ?? editingFile.uploadTime,
|
|
1833
|
+
updateTime: latestMeta?.updateTime ?? editingFile.updateTime,
|
|
1834
|
+
});
|
|
1835
|
+
actionsRef.current.setUploadPercent(undefined);
|
|
1836
|
+
message.success('文件更新成功');
|
|
1837
|
+
loadFiles();
|
|
1838
|
+
} catch (error) {
|
|
1839
|
+
actionsRef.current.setUploadPercent(undefined);
|
|
1840
|
+
console.error('更新/上传文件失败:', error);
|
|
1841
|
+
message.error(typeof error === 'string' ? error : '上传失败');
|
|
1842
|
+
}
|
|
1843
|
+
}, [projectId, api, loadFiles]);
|
|
1844
|
+
|
|
1845
|
+
const handleUploadMarkdown = useCallback(async (editingFile: ProjectFile | null, oldContent: string, content: string, fileName: string) => {
|
|
1846
|
+
if (!editingFile) return;
|
|
1847
|
+
try {
|
|
1848
|
+
const isLocalOnly = !!editingFile.isLocalOnly;
|
|
1849
|
+
const localId = editingFile.localId;
|
|
1850
|
+
let serverFileIdForCache = editingFile.id;
|
|
1851
|
+
const uploadMimeType = fileName.toLowerCase().endsWith('.rdoc') ? 'application/json' : 'text/markdown';
|
|
1852
|
+
const blob = new Blob([content], { type: uploadMimeType });
|
|
1853
|
+
|
|
1854
|
+
if (isLocalOnly) {
|
|
1855
|
+
// ========== 本地新建 Markdown(首次上传)→ CREATE 流程 ==========
|
|
1856
|
+
const { files, selectedFolderId } = stateRef.current;
|
|
1857
|
+
const selectedFolder = files.find((f) => f.id === selectedFolderId);
|
|
1858
|
+
const filePath = selectedFolder ? `${selectedFolder.filePath}${selectedFolder.fileName}/` : '/';
|
|
1859
|
+
|
|
1860
|
+
const { crc32, serverFileName } = await uploadFileBlob(blob, fileName,
|
|
1861
|
+
percent => actionsRef.current.setUploadPercent(percent),
|
|
1862
|
+
success => actionsRef.current.setUploadSuccess(success),
|
|
1863
|
+
);
|
|
1864
|
+
|
|
1865
|
+
const response = await api.addFile(projectId, {
|
|
1866
|
+
fileName,
|
|
1867
|
+
filePath,
|
|
1868
|
+
fileType: uploadMimeType,
|
|
1869
|
+
fileSize: blob.size,
|
|
1870
|
+
crc32,
|
|
1871
|
+
fileResourcePath: serverFileName,
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
if (response.code === ResponseCode.SUCCESS && response.data) {
|
|
1875
|
+
serverFileIdForCache = response.data.fileId;
|
|
1876
|
+
if (localId) {
|
|
1877
|
+
try {
|
|
1878
|
+
await markLocalFileSynced(localId, serverFileIdForCache);
|
|
1879
|
+
await deleteLocalFile(localId);
|
|
1880
|
+
} catch (err) { console.warn('标记本地已同步失败:', err); }
|
|
1881
|
+
}
|
|
1882
|
+
await CacheFileContent.saveToCache(serverFileIdForCache, blob, crc32, blob.size);
|
|
1883
|
+
actionsRef.current.setUploadPercent(undefined);
|
|
1884
|
+
message.success('Markdown 文档上传成功');
|
|
1885
|
+
loadFiles();
|
|
1886
|
+
} else {
|
|
1887
|
+
actionsRef.current.setUploadPercent(undefined);
|
|
1888
|
+
message.error(response.message || '上传失败');
|
|
1889
|
+
}
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// ========== 服务器 Markdown 文件的修改 → UPDATE 流程 ==========
|
|
1894
|
+
// 上传前检查文件是否被其他用户更新过(基于 uploadTime 对比)
|
|
1895
|
+
const conflictInfo = await checkFileUpdateConflict(api, editingFile);
|
|
1896
|
+
if (conflictInfo.result === 'cancel') {
|
|
1897
|
+
actionsRef.current.setUploadPercent(undefined);
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// 差分基准选择:
|
|
1902
|
+
// - 有 localId(未同步修改):优先使用 CacheFileContent 中经过 CRC 校验的服务器内容,
|
|
1903
|
+
// 其次使用本地记录中保存的 originalContent,
|
|
1904
|
+
// 最后回退到 oldContent
|
|
1905
|
+
// - 无 localId(在线编辑):使用 oldContent 作为差分基准
|
|
1906
|
+
let deltaBaseContent = oldContent;
|
|
1907
|
+
if (localId && !conflictInfo.result.includes('overwrite')) {
|
|
1908
|
+
// 优先:从 CacheFileContent 获取并 CRC 校验
|
|
1909
|
+
try {
|
|
1910
|
+
const cached = await CacheFileContent.getCachedContent(editingFile.id);
|
|
1911
|
+
if (cached && conflictInfo.serverCrc32 !== undefined) {
|
|
1912
|
+
const cachedText = await cached.content.text();
|
|
1913
|
+
const cachedCrc = await calculateCRC32(new File([cachedText], fileName, { type: 'text/markdown' }));
|
|
1914
|
+
if (cachedCrc === conflictInfo.serverCrc32) {
|
|
1915
|
+
deltaBaseContent = cachedText;
|
|
1916
|
+
} else {
|
|
1917
|
+
console.warn('[上传诊断] Markdown CacheFileContent CRC 不匹配, cached:', cachedCrc, 'server:', conflictInfo.serverCrc32);
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
} catch (err) {
|
|
1921
|
+
console.warn('[上传诊断] Markdown CacheFileContent 获取失败:', err);
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// 其次:从本地记录的 originalContent 获取
|
|
1925
|
+
if (deltaBaseContent === oldContent) {
|
|
1926
|
+
try {
|
|
1927
|
+
const localRec = await getLocalFile(localId);
|
|
1928
|
+
if (localRec?.originalContent && typeof localRec.originalContent === 'string') {
|
|
1929
|
+
deltaBaseContent = localRec.originalContent;
|
|
1930
|
+
}
|
|
1931
|
+
} catch (err) {
|
|
1932
|
+
console.warn('[上传诊断] Markdown 加载 originalContent 失败:', err);
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
const forceFullUpload = conflictInfo.result === 'overwrite';
|
|
1938
|
+
|
|
1939
|
+
// 差分失败时的确认回调:有 localId 时提示"其他用户已上传新版本"
|
|
1940
|
+
const onDeltaFail = localId ? async () => {
|
|
1941
|
+
const confirmed = await uiConfirm.confirm('已经有其他用户更新了文件,是否全量覆盖上传?');
|
|
1942
|
+
return confirmed;
|
|
1943
|
+
} : undefined;
|
|
1944
|
+
|
|
1945
|
+
const { crc32 } = await uploadMarkdownDelta(
|
|
1946
|
+
editingFile.id,
|
|
1947
|
+
fileName,
|
|
1948
|
+
deltaBaseContent,
|
|
1949
|
+
content,
|
|
1950
|
+
percent => actionsRef.current.setUploadPercent(percent),
|
|
1951
|
+
success => actionsRef.current.setUploadSuccess(success),
|
|
1952
|
+
forceFullUpload,
|
|
1953
|
+
onDeltaFail,
|
|
1954
|
+
{ updateFile: api.updateFile, updateFileDelta: api.updateFileDelta },
|
|
1955
|
+
);
|
|
1956
|
+
await CacheFileContent.removeFromCache(editingFile.id);
|
|
1957
|
+
await CacheFileContent.saveToCache(editingFile.id, blob, crc32, blob.size);
|
|
1958
|
+
|
|
1959
|
+
if (localId) {
|
|
1960
|
+
try {
|
|
1961
|
+
await markLocalFileSynced(localId, editingFile.id);
|
|
1962
|
+
await deleteLocalFile(localId);
|
|
1963
|
+
} catch (err) { console.warn('标记未同步文件为已同步失败:', err); }
|
|
1964
|
+
const refreshed = stateRef.current.files.map(f =>
|
|
1965
|
+
f.id === editingFile.id ? { ...f, hasUnsyncedChanges: false, localId: undefined } : f
|
|
1966
|
+
);
|
|
1967
|
+
actionsRef.current.setFiles(refreshed);
|
|
1968
|
+
actionsRef.current.setFileTreeData(buildFileTree(refreshed));
|
|
1969
|
+
}
|
|
1970
|
+
// 清除编辑器中的"有未同步修改"标记
|
|
1971
|
+
actionsRef.current.setPreviewHasUnsyncedChanges(false);
|
|
1972
|
+
// 重置 previewLocalId:上传成功后旧的 localFiles 记录已被删除
|
|
1973
|
+
actionsRef.current.setPreviewLocalId(null);
|
|
1974
|
+
// 同步更新 editingFile,确保编辑器 props 中的 hasUnsyncedChanges 立即生效
|
|
1975
|
+
// 同时刷新 uploadTime,避免下次冲突检测把自己刚上传的误判为"他人更新"
|
|
1976
|
+
const latestMeta = await fetchLatestFileMeta(api, editingFile.id);
|
|
1977
|
+
actionsRef.current.setEditingFile({
|
|
1978
|
+
...editingFile,
|
|
1979
|
+
hasUnsyncedChanges: false,
|
|
1980
|
+
localId: undefined,
|
|
1981
|
+
uploadTime: latestMeta?.uploadTime ?? editingFile.uploadTime,
|
|
1982
|
+
updateTime: latestMeta?.updateTime ?? editingFile.updateTime,
|
|
1983
|
+
});
|
|
1984
|
+
actionsRef.current.setUploadPercent(undefined);
|
|
1985
|
+
message.success('文档更新成功');
|
|
1986
|
+
loadFiles();
|
|
1987
|
+
} catch (error) {
|
|
1988
|
+
actionsRef.current.setUploadPercent(undefined);
|
|
1989
|
+
console.error('更新/上传失败:', error);
|
|
1990
|
+
message.error('更新失败');
|
|
1991
|
+
}
|
|
1992
|
+
}, [projectId, api, loadFiles]);
|
|
1993
|
+
|
|
1994
|
+
const handleCreateUploadBlob = useCallback(async (initialBlob: Blob | undefined, blob: Blob, fileName: string) => {
|
|
1995
|
+
try {
|
|
1996
|
+
const { files, selectedFolderId } = stateRef.current;
|
|
1997
|
+
const selectedFolder = files.find((f) => f.id === selectedFolderId);
|
|
1998
|
+
const filePath = selectedFolder ? `${selectedFolder.filePath}${selectedFolder.fileName}/` : '/';
|
|
1999
|
+
|
|
2000
|
+
const { crc32, serverFileName } = await uploadFileBlob(blob, fileName,
|
|
2001
|
+
percent => actionsRef.current.setUploadPercent(percent),
|
|
2002
|
+
success => {
|
|
2003
|
+
actionsRef.current.setUploadSuccess(success);
|
|
2004
|
+
}
|
|
2005
|
+
);
|
|
2006
|
+
|
|
2007
|
+
const response = await api.addFile(projectId, {
|
|
2008
|
+
fileName: fileName,
|
|
2009
|
+
filePath: filePath,
|
|
2010
|
+
fileType: getMimeType(fileName),
|
|
2011
|
+
fileSize: blob.size,
|
|
2012
|
+
crc32,
|
|
2013
|
+
fileResourcePath: serverFileName
|
|
2014
|
+
});
|
|
2015
|
+
|
|
2016
|
+
actionsRef.current.setUploadPercent(undefined);
|
|
2017
|
+
|
|
2018
|
+
if (response.code === ResponseCode.SUCCESS) {
|
|
2019
|
+
await CacheFileContent.saveToCache(response.data!.fileId, blob, crc32, blob.size);
|
|
2020
|
+
loadFiles();
|
|
2021
|
+
} else {
|
|
2022
|
+
message.error(response.message || '创建失败');
|
|
2023
|
+
}
|
|
2024
|
+
} catch (error) {
|
|
2025
|
+
actionsRef.current.setUploadPercent(undefined);
|
|
2026
|
+
console.error('创建文件失败:', error);
|
|
2027
|
+
message.error('创建失败');
|
|
2028
|
+
}
|
|
2029
|
+
}, [projectId, api, loadFiles]);
|
|
2030
|
+
|
|
2031
|
+
const handleCreateUploadMarkdown = useCallback(async (oldContent: string, content: string, fileName: string) => {
|
|
2032
|
+
try {
|
|
2033
|
+
const { files, selectedFolderId } = stateRef.current;
|
|
2034
|
+
const selectedFolder = files.find((f) => f.id === selectedFolderId);
|
|
2035
|
+
const filePath = selectedFolder ? `${selectedFolder.filePath}${selectedFolder.fileName}/` : '/';
|
|
2036
|
+
const createMimeType = fileName.toLowerCase().endsWith('.rdoc') ? 'application/json' : 'text/markdown';
|
|
2037
|
+
const blob = new Blob([content], { type: createMimeType });
|
|
2038
|
+
|
|
2039
|
+
const { crc32, serverFileName } = await uploadFileBlob(blob, fileName,
|
|
2040
|
+
percent => actionsRef.current.setUploadPercent(percent),
|
|
2041
|
+
success => actionsRef.current.setUploadSuccess(success),
|
|
2042
|
+
);
|
|
2043
|
+
|
|
2044
|
+
const response = await api.addFile(projectId, {
|
|
2045
|
+
fileName: fileName,
|
|
2046
|
+
filePath: filePath,
|
|
2047
|
+
fileType: createMimeType,
|
|
2048
|
+
fileSize: blob.size,
|
|
2049
|
+
crc32,
|
|
2050
|
+
fileResourcePath: serverFileName
|
|
2051
|
+
});
|
|
2052
|
+
|
|
2053
|
+
actionsRef.current.setUploadPercent(undefined);
|
|
2054
|
+
|
|
2055
|
+
if (response.code === ResponseCode.SUCCESS) {
|
|
2056
|
+
await CacheFileContent.saveToCache(response.data!.fileId, blob, crc32, blob.size);
|
|
2057
|
+
loadFiles();
|
|
2058
|
+
} else {
|
|
2059
|
+
message.error(response.message || '创建失败');
|
|
2060
|
+
}
|
|
2061
|
+
} catch (error) {
|
|
2062
|
+
actionsRef.current.setUploadPercent(undefined);
|
|
2063
|
+
console.error('创建 Markdown 文档失败:', error);
|
|
2064
|
+
message.error('创建失败');
|
|
2065
|
+
}
|
|
2066
|
+
}, [projectId, api, loadFiles]);
|
|
2067
|
+
|
|
2068
|
+
const handleExpand = useCallback((expanded: boolean, record: ProjectFile) => {
|
|
2069
|
+
if (!record.fileType || record.fileType !== 'folder') return;
|
|
2070
|
+
const { expandedRowKeys } = stateRef.current;
|
|
2071
|
+
actionsRef.current.setExpandedRowKeys(expanded
|
|
2072
|
+
? [...expandedRowKeys, record.id]
|
|
2073
|
+
: expandedRowKeys.filter(key => key !== record.id)
|
|
2074
|
+
);
|
|
2075
|
+
actionsRef.current.setSelectedFolderId(expanded ? record.id : null);
|
|
2076
|
+
}, []);
|
|
2077
|
+
|
|
2078
|
+
const toggleFolderExpand = useCallback((record: ProjectFile, tableView: boolean) => {
|
|
2079
|
+
if (record.fileType !== 'folder') return;
|
|
2080
|
+
|
|
2081
|
+
const { expandedRowKeys, selectedFolderId, files } = stateRef.current;
|
|
2082
|
+
|
|
2083
|
+
actionsRef.current.setExpandedRowKeys(expandedRowKeys.includes(record.id)
|
|
2084
|
+
? expandedRowKeys.filter(key => key !== record.id)
|
|
2085
|
+
: [...expandedRowKeys, record.id]
|
|
2086
|
+
);
|
|
2087
|
+
|
|
2088
|
+
actionsRef.current.setSelectedFolderId(selectedFolderId === record.id ? null : record.id);
|
|
2089
|
+
|
|
2090
|
+
if (!tableView) {
|
|
2091
|
+
if (!isFolderExpanded(record.id)) {
|
|
2092
|
+
actionsRef.current.setFileTreeData(buildFileTree(getFileDescendants(record, files)));
|
|
2093
|
+
} else {
|
|
2094
|
+
const parentFolder = files.find((f) => f.id === record.parentId);
|
|
2095
|
+
actionsRef.current.setFileTreeData(buildFileTree(parentFolder
|
|
2096
|
+
? getFileDescendants(parentFolder, files)
|
|
2097
|
+
: files
|
|
2098
|
+
));
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
}, [isFolderExpanded]);
|
|
2102
|
+
|
|
2103
|
+
const handleDragStart = useCallback((fileId: string) => {
|
|
2104
|
+
actionsRef.current.setDragSourceId(fileId);
|
|
2105
|
+
}, []);
|
|
2106
|
+
|
|
2107
|
+
const handleDragEnd = useCallback(() => {
|
|
2108
|
+
actionsRef.current.setDragSourceId(null);
|
|
2109
|
+
actionsRef.current.setDragOverId(null);
|
|
2110
|
+
}, []);
|
|
2111
|
+
|
|
2112
|
+
const handleDragOver = useCallback((fileId: string) => {
|
|
2113
|
+
actionsRef.current.setDragOverId(fileId);
|
|
2114
|
+
}, []);
|
|
2115
|
+
|
|
2116
|
+
const handleDragLeave = useCallback((fileId: string) => {
|
|
2117
|
+
if (stateRef.current.dragOverId === fileId) {
|
|
2118
|
+
actionsRef.current.setDragOverId(null);
|
|
2119
|
+
}
|
|
2120
|
+
}, []);
|
|
2121
|
+
|
|
2122
|
+
const handleDrop = useCallback((sourceId: string, targetId: string) => {
|
|
2123
|
+
handleMove(sourceId, targetId);
|
|
2124
|
+
actionsRef.current.setDragOverId(null);
|
|
2125
|
+
}, [handleMove]);
|
|
2126
|
+
|
|
2127
|
+
const handleExternalDragEnter = useCallback((e: React.DragEvent) => {
|
|
2128
|
+
e.preventDefault();
|
|
2129
|
+
if (e.dataTransfer.types.includes('Files') || e.dataTransfer.items.length > 0) {
|
|
2130
|
+
externalDragCounter.current++;
|
|
2131
|
+
actionsRef.current.setIsExternalDragOver(true);
|
|
2132
|
+
}
|
|
2133
|
+
}, []);
|
|
2134
|
+
|
|
2135
|
+
const handleExternalDragOver = useCallback((e: React.DragEvent) => {
|
|
2136
|
+
e.preventDefault();
|
|
2137
|
+
if (e.dataTransfer.types.includes('Files') || e.dataTransfer.items.length > 0) {
|
|
2138
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
2139
|
+
}
|
|
2140
|
+
}, []);
|
|
2141
|
+
|
|
2142
|
+
const handleExternalDragLeave = useCallback((e: React.DragEvent) => {
|
|
2143
|
+
externalDragCounter.current--;
|
|
2144
|
+
if (externalDragCounter.current <= 0) {
|
|
2145
|
+
externalDragCounter.current = 0;
|
|
2146
|
+
actionsRef.current.setIsExternalDragOver(false);
|
|
2147
|
+
}
|
|
2148
|
+
}, []);
|
|
2149
|
+
|
|
2150
|
+
const handleExternalDrop = useCallback(async (e: React.DragEvent) => {
|
|
2151
|
+
e.preventDefault();
|
|
2152
|
+
externalDragCounter.current = 0;
|
|
2153
|
+
actionsRef.current.setIsExternalDragOver(false);
|
|
2154
|
+
|
|
2155
|
+
const droppedFiles = await extractFilesFromDrop(e.dataTransfer.items);
|
|
2156
|
+
if (droppedFiles.length === 0) return;
|
|
2157
|
+
|
|
2158
|
+
try {
|
|
2159
|
+
const { files, selectedFolderId } = stateRef.current;
|
|
2160
|
+
const selectedFolder = files.find((f) => f.id === selectedFolderId);
|
|
2161
|
+
const basePath = selectedFolder ? `${selectedFolder.filePath}${selectedFolder.fileName}/` : '/';
|
|
2162
|
+
const allCreatedFolders = new Set<string>();
|
|
2163
|
+
|
|
2164
|
+
const existingFolderKeys = new Set(
|
|
2165
|
+
files
|
|
2166
|
+
.filter(f => f.fileType === 'folder')
|
|
2167
|
+
.map(f => `${f.filePath}${f.fileName}`)
|
|
2168
|
+
);
|
|
2169
|
+
|
|
2170
|
+
for (const [index, { file, relativePath }] of droppedFiles.entries()) {
|
|
2171
|
+
actionsRef.current.setDragUploadingFileName(droppedFiles.length > 1 ? `${relativePath} (${index + 1}/${droppedFiles.length})` : relativePath);
|
|
2172
|
+
actionsRef.current.setDragUploadPercent(0);
|
|
2173
|
+
actionsRef.current.setDragUploadSuccess(undefined);
|
|
2174
|
+
|
|
2175
|
+
const lastSlashIndex = relativePath.lastIndexOf('/');
|
|
2176
|
+
const fileName = lastSlashIndex >= 0 ? relativePath.substring(lastSlashIndex + 1) : relativePath;
|
|
2177
|
+
const folderPath = lastSlashIndex >= 0 ? relativePath.substring(0, lastSlashIndex) : '';
|
|
2178
|
+
|
|
2179
|
+
let targetFilePath = basePath;
|
|
2180
|
+
if (folderPath) {
|
|
2181
|
+
const fullFolderPath = basePath + folderPath + '/';
|
|
2182
|
+
if (!allCreatedFolders.has(fullFolderPath)) {
|
|
2183
|
+
const parts = folderPath.split('/');
|
|
2184
|
+
let currentPath = basePath;
|
|
2185
|
+
|
|
2186
|
+
for (const part of parts) {
|
|
2187
|
+
const sanitizedPart = sanitizeFileName(part);
|
|
2188
|
+
const folderKey = `${currentPath}${sanitizedPart}`;
|
|
2189
|
+
|
|
2190
|
+
if (!existingFolderKeys.has(folderKey) && !allCreatedFolders.has(folderKey)) {
|
|
2191
|
+
await api.addFile(projectId, {
|
|
2192
|
+
fileName: sanitizedPart,
|
|
2193
|
+
filePath: currentPath,
|
|
2194
|
+
fileType: 'folder',
|
|
2195
|
+
fileSize: 0,
|
|
2196
|
+
fileResourcePath: '',
|
|
2197
|
+
isSecure: false,
|
|
2198
|
+
});
|
|
2199
|
+
allCreatedFolders.add(folderKey);
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
currentPath = `${currentPath}${sanitizedPart}/`;
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
allCreatedFolders.add(fullFolderPath);
|
|
2206
|
+
targetFilePath = fullFolderPath;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
const { crc32, serverFileName } = await uploadFileBlob(file, fileName,
|
|
2210
|
+
(p) => actionsRef.current.setDragUploadPercent(p),
|
|
2211
|
+
(s) => actionsRef.current.setDragUploadSuccess(s),
|
|
2212
|
+
);
|
|
2213
|
+
|
|
2214
|
+
const response = await api.addFile(projectId, {
|
|
2215
|
+
fileName: fileName,
|
|
2216
|
+
filePath: targetFilePath,
|
|
2217
|
+
fileType: file.type || 'application/octet-stream',
|
|
2218
|
+
fileSize: file.size,
|
|
2219
|
+
crc32,
|
|
2220
|
+
fileResourcePath: serverFileName,
|
|
2221
|
+
});
|
|
2222
|
+
|
|
2223
|
+
if (response.code !== ResponseCode.SUCCESS) {
|
|
2224
|
+
actionsRef.current.setDragUploadSuccess(false);
|
|
2225
|
+
message.error(response.message || `文件 ${relativePath} 上传失败`);
|
|
2226
|
+
} else {
|
|
2227
|
+
// 上传成功后保存缓存
|
|
2228
|
+
if (response.data?.fileId) {
|
|
2229
|
+
await CacheFileContent.saveToCache(response.data.fileId, file as Blob, crc32, file.size);
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
loadFiles();
|
|
2235
|
+
} catch (error) {
|
|
2236
|
+
console.error('文件上传失败:', error);
|
|
2237
|
+
actionsRef.current.setDragUploadSuccess(false);
|
|
2238
|
+
if (typeof error === 'string') {
|
|
2239
|
+
message.error('文件上传失败: ' + error);
|
|
2240
|
+
} else {
|
|
2241
|
+
message.error('文件上传失败');
|
|
2242
|
+
}
|
|
2243
|
+
} finally {
|
|
2244
|
+
window.setTimeout(() => {
|
|
2245
|
+
actionsRef.current.setDragUploadPercent(undefined);
|
|
2246
|
+
actionsRef.current.setDragUploadSuccess(undefined);
|
|
2247
|
+
actionsRef.current.setDragUploadingFileName('');
|
|
2248
|
+
}, 1000);
|
|
2249
|
+
}
|
|
2250
|
+
}, [projectId, api, loadFiles]);
|
|
2251
|
+
|
|
2252
|
+
const handleContextMenu = useCallback((event: React.MouseEvent, record: ProjectFile) => {
|
|
2253
|
+
event.preventDefault();
|
|
2254
|
+
actionsRef.current.setContextMenuRecord(record);
|
|
2255
|
+
actionsRef.current.setContextMenuPosition({ x: event.clientX, y: event.clientY });
|
|
2256
|
+
actionsRef.current.setContextMenuVisible(true);
|
|
2257
|
+
}, []);
|
|
2258
|
+
|
|
2259
|
+
const handleMoveToProject = useCallback((file: ProjectFile) => {
|
|
2260
|
+
actionsRef.current.setMoveCopyFiles([file]);
|
|
2261
|
+
actionsRef.current.setMoveCopyMode('move');
|
|
2262
|
+
actionsRef.current.setMoveCopyModalVisible(true);
|
|
2263
|
+
actionsRef.current.setContextMenuVisible(false);
|
|
2264
|
+
actionsRef.current.setContextMenuRecord(null);
|
|
2265
|
+
}, []);
|
|
2266
|
+
|
|
2267
|
+
const handleCopyToProject = useCallback((file: ProjectFile) => {
|
|
2268
|
+
actionsRef.current.setMoveCopyFiles([file]);
|
|
2269
|
+
actionsRef.current.setMoveCopyMode('copy');
|
|
2270
|
+
actionsRef.current.setMoveCopyModalVisible(true);
|
|
2271
|
+
actionsRef.current.setContextMenuVisible(false);
|
|
2272
|
+
actionsRef.current.setContextMenuRecord(null);
|
|
2273
|
+
}, []);
|
|
2274
|
+
|
|
2275
|
+
const handleCopyToSelf = useCallback(async (file: ProjectFile) => {
|
|
2276
|
+
actionsRef.current.setContextMenuVisible(false);
|
|
2277
|
+
actionsRef.current.setContextMenuRecord(null);
|
|
2278
|
+
if (!api.copyFileToProject) return;
|
|
2279
|
+
try {
|
|
2280
|
+
const response = await api.copyFileToProject(projectId, file.id);
|
|
2281
|
+
if (response.code === ResponseCode.SUCCESS) {
|
|
2282
|
+
message.success(`复制成功,文件名为:${response.data}`);
|
|
2283
|
+
loadFiles();
|
|
2284
|
+
} else {
|
|
2285
|
+
message.error(response.message || '复制失败');
|
|
2286
|
+
}
|
|
2287
|
+
} catch (error) {
|
|
2288
|
+
message.error('复制失败');
|
|
2289
|
+
console.error('复制副本失败:', error);
|
|
2290
|
+
}
|
|
2291
|
+
}, [projectId, api, loadFiles]);
|
|
2292
|
+
|
|
2293
|
+
const handleMoveCopySuccess = useCallback(() => {
|
|
2294
|
+
actionsRef.current.setMoveCopyModalVisible(false);
|
|
2295
|
+
actionsRef.current.setMoveCopyFiles([]);
|
|
2296
|
+
loadFiles();
|
|
2297
|
+
}, [loadFiles]);
|
|
2298
|
+
|
|
2299
|
+
const handleViewVersionHistory = useCallback((file: ProjectFile) => {
|
|
2300
|
+
actionsRef.current.setVersionHistoryFile(file);
|
|
2301
|
+
actionsRef.current.setVersionHistoryModalVisible(true);
|
|
2302
|
+
actionsRef.current.setContextMenuVisible(false);
|
|
2303
|
+
actionsRef.current.setContextMenuRecord(null);
|
|
2304
|
+
}, []);
|
|
2305
|
+
|
|
2306
|
+
const handleOpenInNewWindow = useCallback((file: ProjectFile) => {
|
|
2307
|
+
if (file.fileType === 'folder') return;
|
|
2308
|
+
const fileLink = `${window.location.origin}/filepreview?fileid=${file.id}&path=${encodeURIComponent(file.fileResourcePath)}&name=${encodeURIComponent(file.fileName)}`;
|
|
2309
|
+
window.open(fileLink, '_blank', 'noopener,noreferrer');
|
|
2310
|
+
actionsRef.current.setContextMenuVisible(false);
|
|
2311
|
+
actionsRef.current.setContextMenuRecord(null);
|
|
2312
|
+
}, []);
|
|
2313
|
+
|
|
2314
|
+
const handleRootDragOver = useCallback((e: React.DragEvent) => {
|
|
2315
|
+
e.preventDefault();
|
|
2316
|
+
actionsRef.current.setRootDragOver(true);
|
|
2317
|
+
}, []);
|
|
2318
|
+
|
|
2319
|
+
const handleRootDragLeave = useCallback(() => {
|
|
2320
|
+
actionsRef.current.setRootDragOver(false);
|
|
2321
|
+
}, []);
|
|
2322
|
+
|
|
2323
|
+
const handleRootDrop = useCallback((e: React.DragEvent) => {
|
|
2324
|
+
e.preventDefault();
|
|
2325
|
+
actionsRef.current.setRootDragOver(false);
|
|
2326
|
+
const sourceId = e.dataTransfer.getData('text/plain');
|
|
2327
|
+
if (sourceId) {
|
|
2328
|
+
handleMove(sourceId, null);
|
|
2329
|
+
}
|
|
2330
|
+
}, [handleMove]);
|
|
2331
|
+
|
|
2332
|
+
const initUserRole = useCallback(() => {
|
|
2333
|
+
if (!api.resolveIsManager) return;
|
|
2334
|
+
api.resolveIsManager(projectId).then(isManager => {
|
|
2335
|
+
if (!isManager) {
|
|
2336
|
+
actionsRef.current.setIsManager(false);
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
// 非私有项目需要额外检查文件夹下载权限
|
|
2341
|
+
const checkFolderDownloadPermission = async (): Promise<boolean> => {
|
|
2342
|
+
const userInfo = await CacheUserInfo.getUserInfo({ forceRefresh: false });
|
|
2343
|
+
const userId = userInfo?.id || 'local-user';
|
|
2344
|
+
const isPrivateProject = projectId === userId;
|
|
2345
|
+
if (isPrivateProject) {
|
|
2346
|
+
return true;
|
|
2347
|
+
}
|
|
2348
|
+
return CachePermissionInfo.hasPermission({ permission: PERMISSION_FOLDER_DOWNLOAD });
|
|
2349
|
+
};
|
|
2350
|
+
|
|
2351
|
+
checkFolderDownloadPermission().then(hasPermission => {
|
|
2352
|
+
if (hasPermission) {
|
|
2353
|
+
actionsRef.current.setIsManager(true);
|
|
2354
|
+
}
|
|
2355
|
+
}).catch(() => { /* 忽略权限检查失败,保持默认非管理员 */ });
|
|
2356
|
+
}).catch(() => { /* 忽略角色解析失败,保持默认非管理员 */ });
|
|
2357
|
+
}, [projectId, api]);
|
|
2358
|
+
|
|
2359
|
+
const initViewMode = useCallback(async () => {
|
|
2360
|
+
const cached = await getViewMode(PAGE_KEY);
|
|
2361
|
+
if (cached && (cached === 'grid' || cached === 'table')) {
|
|
2362
|
+
actionsRef.current.setViewMode(cached);
|
|
2363
|
+
}
|
|
2364
|
+
}, []);
|
|
2365
|
+
|
|
2366
|
+
const handleClearCache = useCallback(async (file: ProjectFile) => {
|
|
2367
|
+
try {
|
|
2368
|
+
// 1. 清除文件内容缓存(IndexedDB fileCache store)
|
|
2369
|
+
await CacheFileContent.removeFromCache(file.id);
|
|
2370
|
+
// 2. 清除文件元数据缓存(IndexedDB fileMeta store)
|
|
2371
|
+
try {
|
|
2372
|
+
await CacheFileMeta.removeFileMeta(file.id);
|
|
2373
|
+
} catch (err) {
|
|
2374
|
+
console.warn('删除 fileMeta 记录失败:', err);
|
|
2375
|
+
}
|
|
2376
|
+
// 3. 清除 localFiles store 中的未同步记录(含 hasUnsyncedChanges 标记)
|
|
2377
|
+
if (file.localId) {
|
|
2378
|
+
try {
|
|
2379
|
+
await deleteLocalFile(file.localId);
|
|
2380
|
+
} catch (err) {
|
|
2381
|
+
console.warn('删除 localFiles 记录失败:', err);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
message.success('缓存清理成功');
|
|
2385
|
+
// 4. 更新文件列表状态:清除 hasLocalCache、hasUnsyncedChanges、localId
|
|
2386
|
+
const { files } = stateRef.current;
|
|
2387
|
+
const updatedFiles = files.map(f =>
|
|
2388
|
+
f.id === file.id ? { ...f, hasLocalCache: false, hasUnsyncedChanges: false, localId: undefined } : f
|
|
2389
|
+
);
|
|
2390
|
+
actionsRef.current.setFileTreeData(buildFileTree(updatedFiles));
|
|
2391
|
+
actionsRef.current.setFiles(updatedFiles);
|
|
2392
|
+
actionsRef.current.setContextMenuVisible(false);
|
|
2393
|
+
actionsRef.current.setContextMenuRecord(null);
|
|
2394
|
+
} catch (error) {
|
|
2395
|
+
console.error('清理缓存失败:', error);
|
|
2396
|
+
message.error('清理缓存失败');
|
|
2397
|
+
}
|
|
2398
|
+
}, []);
|
|
2399
|
+
|
|
2400
|
+
const handleCacheSaved = useCallback((fileId: string) => {
|
|
2401
|
+
const { files } = stateRef.current;
|
|
2402
|
+
const file = files.find(f => f.id === fileId);
|
|
2403
|
+
if (file && !file.hasLocalCache) {
|
|
2404
|
+
const updatedFiles = files.map(f =>
|
|
2405
|
+
f.id === fileId ? { ...f, hasLocalCache: true } : f
|
|
2406
|
+
);
|
|
2407
|
+
actionsRef.current.setFileTreeData(buildFileTree(updatedFiles));
|
|
2408
|
+
actionsRef.current.setFiles(updatedFiles);
|
|
2409
|
+
}
|
|
2410
|
+
}, []);
|
|
2411
|
+
handleCacheSavedRef.current = handleCacheSaved;
|
|
2412
|
+
|
|
2413
|
+
return useMemo(() => ({
|
|
2414
|
+
loadFiles,
|
|
2415
|
+
uploadFiles,
|
|
2416
|
+
handlePreview,
|
|
2417
|
+
handleDownload,
|
|
2418
|
+
handleDownloadFolder,
|
|
2419
|
+
handleShareToWechat,
|
|
2420
|
+
handleCopyLink,
|
|
2421
|
+
handleJumpToContract,
|
|
2422
|
+
handleCreateContract,
|
|
2423
|
+
handleGenerateTempLink,
|
|
2424
|
+
handleDelete,
|
|
2425
|
+
isFolderExpanded,
|
|
2426
|
+
handleMove,
|
|
2427
|
+
handleRename,
|
|
2428
|
+
handleEditDescs,
|
|
2429
|
+
handleDescsConfirm,
|
|
2430
|
+
handleRenameConfirm,
|
|
2431
|
+
handleCreateFolder,
|
|
2432
|
+
openCreator,
|
|
2433
|
+
handleCreatePptx,
|
|
2434
|
+
handleCreateMarkdown,
|
|
2435
|
+
handleCreateRdoc,
|
|
2436
|
+
handleCreateExcel,
|
|
2437
|
+
handleCreateWord,
|
|
2438
|
+
handleCreateFileConfirm,
|
|
2439
|
+
handleCreateFolderConfirm,
|
|
2440
|
+
handleEditFile,
|
|
2441
|
+
handleUploadBlob,
|
|
2442
|
+
handleUploadMarkdown,
|
|
2443
|
+
handleCreateUploadBlob,
|
|
2444
|
+
handleCreateUploadMarkdown,
|
|
2445
|
+
handleSaveLocalBlob,
|
|
2446
|
+
handleSaveLocalMarkdown,
|
|
2447
|
+
handleExpand,
|
|
2448
|
+
toggleFolderExpand,
|
|
2449
|
+
handleDragStart,
|
|
2450
|
+
handleDragEnd,
|
|
2451
|
+
handleDragOver,
|
|
2452
|
+
handleDragLeave,
|
|
2453
|
+
handleDrop,
|
|
2454
|
+
handleExternalDragEnter,
|
|
2455
|
+
handleExternalDragOver,
|
|
2456
|
+
handleExternalDragLeave,
|
|
2457
|
+
handleExternalDrop,
|
|
2458
|
+
handleContextMenu,
|
|
2459
|
+
handleMoveToProject,
|
|
2460
|
+
handleCopyToProject,
|
|
2461
|
+
handleCopyToSelf,
|
|
2462
|
+
handleMoveCopySuccess,
|
|
2463
|
+
handleViewVersionHistory,
|
|
2464
|
+
handleClearCache,
|
|
2465
|
+
handleCacheSaved,
|
|
2466
|
+
handleOpenInNewWindow,
|
|
2467
|
+
handleRootDragOver,
|
|
2468
|
+
handleRootDragLeave,
|
|
2469
|
+
handleRootDrop,
|
|
2470
|
+
initUserRole,
|
|
2471
|
+
initViewMode,
|
|
2472
|
+
}), [
|
|
2473
|
+
loadFiles,
|
|
2474
|
+
uploadFiles,
|
|
2475
|
+
handlePreview,
|
|
2476
|
+
handleDownload,
|
|
2477
|
+
handleDownloadFolder,
|
|
2478
|
+
handleShareToWechat,
|
|
2479
|
+
handleCopyLink,
|
|
2480
|
+
handleJumpToContract,
|
|
2481
|
+
handleCreateContract,
|
|
2482
|
+
handleGenerateTempLink,
|
|
2483
|
+
handleDelete,
|
|
2484
|
+
isFolderExpanded,
|
|
2485
|
+
handleMove,
|
|
2486
|
+
handleRename,
|
|
2487
|
+
handleEditDescs,
|
|
2488
|
+
handleDescsConfirm,
|
|
2489
|
+
handleRenameConfirm,
|
|
2490
|
+
handleCreateFolder,
|
|
2491
|
+
openCreator,
|
|
2492
|
+
handleCreatePptx,
|
|
2493
|
+
handleCreateMarkdown,
|
|
2494
|
+
handleCreateRdoc,
|
|
2495
|
+
handleCreateExcel,
|
|
2496
|
+
handleCreateWord,
|
|
2497
|
+
handleCreateFileConfirm,
|
|
2498
|
+
handleCreateFolderConfirm,
|
|
2499
|
+
handleEditFile,
|
|
2500
|
+
handleUploadBlob,
|
|
2501
|
+
handleUploadMarkdown,
|
|
2502
|
+
handleCreateUploadBlob,
|
|
2503
|
+
handleCreateUploadMarkdown,
|
|
2504
|
+
handleSaveLocalBlob,
|
|
2505
|
+
handleSaveLocalMarkdown,
|
|
2506
|
+
handleExpand,
|
|
2507
|
+
toggleFolderExpand,
|
|
2508
|
+
handleDragStart,
|
|
2509
|
+
handleDragEnd,
|
|
2510
|
+
handleDragOver,
|
|
2511
|
+
handleDragLeave,
|
|
2512
|
+
handleDrop,
|
|
2513
|
+
handleExternalDragEnter,
|
|
2514
|
+
handleExternalDragOver,
|
|
2515
|
+
handleExternalDragLeave,
|
|
2516
|
+
handleExternalDrop,
|
|
2517
|
+
handleContextMenu,
|
|
2518
|
+
handleMoveToProject,
|
|
2519
|
+
handleCopyToProject,
|
|
2520
|
+
handleCopyToSelf,
|
|
2521
|
+
handleMoveCopySuccess,
|
|
2522
|
+
handleViewVersionHistory,
|
|
2523
|
+
handleClearCache,
|
|
2524
|
+
handleCacheSaved,
|
|
2525
|
+
handleOpenInNewWindow,
|
|
2526
|
+
handleRootDragOver,
|
|
2527
|
+
handleRootDragLeave,
|
|
2528
|
+
handleRootDrop,
|
|
2529
|
+
initUserRole,
|
|
2530
|
+
initViewMode,
|
|
2531
|
+
]);
|
|
2532
|
+
};
|